| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { G1T_KEY, isG1t, parsePrincipalKey } from "@g1t/contracts/chat"; |
| 5 | import { ASK_MAX_MESSAGE_CHARS, ASK_MAX_TOTAL_CHARS } from "@g1t/contracts/integrations"; |
| 6 | |
| 7 | import { dmKey, dmMembers } from "./names.ts"; |
| 8 | import { |
| 9 | AnswerReader, |
| 10 | answerAllowed, |
| 11 | conversation, |
| 12 | finalBody, |
| 13 | g1tDmPerson, |
| 14 | latestPage, |
| 15 | metaColumn, |
| 16 | notSetUpText, |
| 17 | pageFrom, |
| 18 | shownAnswer, |
| 19 | startsAnswer, |
| 20 | type SaidRow, |
| 21 | } from "./g1t.ts"; |
| 22 | |
| 23 | const ana = "user:u1"; |
| 24 | const bo = "user:u2"; |
| 25 | |
| 26 | test("g1t is one reserved member, system:g1t, and only that system key parses", () => { |
| 27 | assert.deepEqual(parsePrincipalKey(G1T_KEY), { kind: "system", id: "g1t" }); |
| 28 | assert.equal(parsePrincipalKey("system:other"), null); |
| 29 | assert.ok(isG1t({ kind: "system", id: "g1t" })); |
| 30 | assert.ok(!isG1t({ kind: "user", id: "g1t" })); |
| 31 | }); |
| 32 | |
| 33 | test("a direct message with g1t is one person and g1t, keyed like any other", () => { |
| 34 | const members = dmMembers(ana, [G1T_KEY]); |
| 35 | assert.equal(g1tDmPerson(members), ana); |
| 36 | // Opening it again, from either side's order, finds the same one. |
| 37 | assert.equal(dmKey(members), dmKey(dmMembers(ana, [G1T_KEY, G1T_KEY]))); |
| 38 | assert.equal(g1tDmPerson(dmMembers(ana, [G1T_KEY, bo])), null); |
| 39 | assert.equal(g1tDmPerson(dmMembers(ana, [bo])), null); |
| 40 | assert.equal(g1tDmPerson([G1T_KEY]), null); |
| 41 | assert.equal(g1tDmPerson(["agent:a1", G1T_KEY]), null); |
| 42 | }); |
| 43 | |
| 44 | test("only a person's own top-level words in their direct message with g1t start an answer", () => { |
| 45 | const members = [G1T_KEY, ana]; |
| 46 | const base = { channelKind: "dm" as const, members, author: ana, threadRoot: null, card: false, body: "What shipped today?" }; |
| 47 | assert.ok(startsAnswer(base)); |
| 48 | assert.ok(!startsAnswer({ ...base, author: G1T_KEY }), "g1t's own messages never do"); |
| 49 | assert.ok(!startsAnswer({ ...base, threadRoot: "msg_1" }), "nor thread replies"); |
| 50 | assert.ok(!startsAnswer({ ...base, card: true }), "nor cards"); |
| 51 | assert.ok(!startsAnswer({ ...base, body: " " })); |
| 52 | assert.ok(!startsAnswer({ ...base, members: [ana, bo] }), "a direct message between people"); |
| 53 | assert.ok(!startsAnswer({ ...base, channelKind: "channel" }), "a channel, even one g1t were in"); |
| 54 | assert.ok(!startsAnswer({ ...base, author: bo }), "someone not in it"); |
| 55 | }); |
| 56 | |
| 57 | const row = (id: string, author: string, body: string, meta: object | null = null, extra: Partial<SaidRow> = {}): SaidRow => ({ |
| 58 | id, |
| 59 | author, |
| 60 | kind: "text", |
| 61 | body, |
| 62 | meta: meta ? JSON.stringify(meta) : null, |
| 63 | deleted_at: null, |
| 64 | ...extra, |
| 65 | }); |
| 66 | const done = { answer: { state: "done" } }; |
| 67 | |
| 68 | test("the conversation maps the person to user and g1t's answers to assistant, alternating", () => { |
| 69 | const rows = [ |
| 70 | row("m1", ana, "Hi"), |
| 71 | row("m2", G1T_KEY, "Hello! What can I do?", done), |
| 72 | row("m3", ana, "What shipped?"), |
| 73 | row("m4", ana, "Today, I mean."), |
| 74 | ]; |
| 75 | assert.deepEqual(conversation(rows), [ |
| 76 | { role: "user", content: "Hi" }, |
| 77 | { role: "assistant", content: "Hello! What can I do?" }, |
| 78 | { role: "user", content: "What shipped?\n\nToday, I mean." }, |
| 79 | ]); |
| 80 | }); |
| 81 | |
| 82 | test("the conversation leaves out failed answers, the set-up note, the answer being written, cards and deleted messages", () => { |
| 83 | const rows = [ |
| 84 | row("m1", ana, "First"), |
| 85 | row("m2", G1T_KEY, "I couldn't answer that", { answer: { state: "error", error: "x" } }), |
| 86 | row("m3", G1T_KEY, "Choose a provider", { answer: { state: "done", setup: "missing" } }), |
| 87 | row("m4", ana, "gone", null, { deleted_at: "2026-10-12T00:00:00Z" }), |
| 88 | row("m5", ana, "card", null, { kind: "card" }), |
| 89 | row("m6", ana, "Second"), |
| 90 | row("m7", G1T_KEY, "", { answer: { state: "thinking" } }), |
| 91 | ]; |
| 92 | assert.deepEqual(conversation(rows), [{ role: "user", content: "First\n\nSecond" }]); |
| 93 | }); |
| 94 | |
| 95 | test("a stopped answer still counts as what g1t said", () => { |
| 96 | const rows = [row("m1", ana, "Q"), row("m2", G1T_KEY, "Half an ans", { answer: { state: "stopped" } }), row("m3", ana, "Go on")]; |
| 97 | assert.equal(conversation(rows).length, 3); |
| 98 | }); |
| 99 | |
| 100 | test("a new conversation reads only what comes after the latest mark", () => { |
| 101 | const rows = [ |
| 102 | row("m1", ana, "Old question"), |
| 103 | row("m2", G1T_KEY, "Old answer", done), |
| 104 | row("m3", G1T_KEY, "New conversation", { reset: true }), |
| 105 | row("m4", ana, "Fresh question"), |
| 106 | ]; |
| 107 | assert.deepEqual(conversation(rows), [{ role: "user", content: "Fresh question" }]); |
| 108 | assert.deepEqual(conversation(rows.slice(0, 3)), [], "nothing to answer right after a mark"); |
| 109 | }); |
| 110 | |
| 111 | test("the conversation ends with the person and keeps within Ask's limits, newest first", () => { |
| 112 | assert.deepEqual(conversation([row("m1", ana, "Q"), row("m2", G1T_KEY, "A", done)]), []); |
| 113 | const long = "x".repeat(ASK_MAX_MESSAGE_CHARS + 50); |
| 114 | const [only] = conversation([row("m1", ana, `start${long}end`)]); |
| 115 | assert.equal(only!.content.length, ASK_MAX_MESSAGE_CHARS); |
| 116 | assert.ok(only!.content.endsWith("end"), "a long question keeps its end"); |
| 117 | const many: SaidRow[] = []; |
| 118 | for (let i = 0; i < 60; i++) many.push(row(`q${i}`, ana, `q${i} ${"y".repeat(3_000)}`), row(`a${i}`, G1T_KEY, `a${i} ${"z".repeat(3_000)}`, done)); |
| 119 | many.push(row("last", ana, "the latest")); |
| 120 | const kept = conversation(many); |
| 121 | assert.equal(kept[0]!.role, "user"); |
| 122 | assert.equal(kept.at(-1)!.content, "the latest"); |
| 123 | assert.ok(kept.length <= 40); |
| 124 | assert.ok(kept.reduce((sum, turn) => sum + turn.content.length, 0) <= ASK_MAX_TOTAL_CHARS); |
| 125 | for (let i = 1; i < kept.length; i++) assert.notEqual(kept[i]!.role, kept[i - 1]!.role); |
| 126 | }); |
| 127 | |
| 128 | test("the page an answer is told is the newest question's, as sent from the top bar", () => { |
| 129 | const page = { path: "/acme/web/issues/12", repo: { namespace: "acme", name: "web" }, issue: 12, pull: null }; |
| 130 | assert.deepEqual(latestPage([row("m1", ana, "Q", { page }), row("m2", G1T_KEY, "A", done)]), page); |
| 131 | assert.equal(latestPage([row("m1", ana, "Q", { page }), row("m2", ana, "From chat")]), null); |
| 132 | assert.deepEqual(pageFrom({ path: "/acme/web", repo: { namespace: "acme", name: "web" }, issue: -1, extra: true }), { |
| 133 | path: "/acme/web", |
| 134 | repo: { namespace: "acme", name: "web" }, |
| 135 | issue: null, |
| 136 | pull: null, |
| 137 | time_zone: null, |
| 138 | }); |
| 139 | // The browser's time zone is kept when it is one; anything else is dropped. |
| 140 | assert.equal(pageFrom({ path: "/acme", time_zone: "America/Denver" })?.time_zone, "America/Denver"); |
| 141 | assert.equal(pageFrom({ path: "/acme", time_zone: "<script>" })?.time_zone, null); |
| 142 | assert.deepEqual(pageFrom({ time_zone: "Asia/Kolkata" }), { path: null, repo: null, issue: null, pull: null, time_zone: "Asia/Kolkata" }); |
| 143 | assert.equal(pageFrom({ path: "https://elsewhere" }), null); |
| 144 | assert.equal(pageFrom("nope"), null); |
| 145 | assert.equal(metaColumn({}), null); |
| 146 | }); |
| 147 | |
| 148 | test("with no provider chosen, g1t says where an owner chooses one", () => { |
| 149 | const text = notSetUpText("acme"); |
| 150 | assert.match(text, /Integrations → Your AI providers → Ask g1t/); |
| 151 | assert.match(text, /\(\/acme\/-\/integrations\/models#ask\)/); |
| 152 | assert.match(text, /ask one of them/); |
| 153 | }); |
| 154 | |
| 155 | test("an answer is read as it streams, and unknown events are kept as steps, never text", () => { |
| 156 | const reader = new AnswerReader(); |
| 157 | assert.ok(reader.feed('{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"m"}\n{"type":"te')); |
| 158 | assert.equal(reader.answer.state, "thinking"); |
| 159 | reader.feed('xt","text":"Hel"}\n{"type":"tool","name":"search","input":{"q":"x"}}\n'); |
| 160 | reader.feed('{"type":"text","text":"lo"}\nnot json\n{"type":"done","stop_reason":"end_turn"}\n{"type":"text","text":"after"}\n'); |
| 161 | assert.equal(reader.body, "Hello"); |
| 162 | assert.equal(reader.answer.state, "done"); |
| 163 | assert.equal(reader.answer.provider_label, "Anthropic"); |
| 164 | assert.deepEqual(reader.answer.steps, [{ type: "tool", name: "search", input: { q: "x" } }]); |
| 165 | }); |
| 166 | |
| 167 | test("a tool step is kept once, as it last was, and sources are kept whole", () => { |
| 168 | const reader = new AnswerReader(); |
| 169 | const step = { type: "tool", id: "step_0_0", name: "workspace", action: "activity", label: "Read activity", status: "running", summary: null, link: null }; |
| 170 | reader.feed(`${JSON.stringify(step)}\n${JSON.stringify({ ...step, id: "step_0_1" })}\n`); |
| 171 | reader.feed(`${JSON.stringify({ ...step, status: "done", summary: "12 results" })}\n`); |
| 172 | reader.feed('{"type":"sources","sources":[{"title":"a","url":"https://g1t.sh/a","cited":true}]}\n'); |
| 173 | reader.feed('{"type":"sources","sources":[{"title":"b","url":"https://g1t.sh/b","cited":true}]}\n'); |
| 174 | const steps = reader.answer.steps ?? []; |
| 175 | assert.deepEqual( |
| 176 | steps.map((kept) => [kept.type, kept.id ?? null, kept.status ?? null]), |
| 177 | [ |
| 178 | ["tool", "step_0_0", "done"], |
| 179 | ["tool", "step_0_1", "running"], |
| 180 | ["sources", null, null], |
| 181 | ], |
| 182 | ); |
| 183 | assert.equal((steps[2] as { sources: { title: string }[] }).sources[0]!.title, "b"); |
| 184 | }); |
| 185 | |
| 186 | test("an answer that breaks off or fails is never silence", () => { |
| 187 | const broken = new AnswerReader(); |
| 188 | broken.feed('{"type":"text","text":"Half"}\n'); |
| 189 | broken.finish(); |
| 190 | assert.equal(broken.answer.state, "error"); |
| 191 | assert.equal(finalBody(broken.body, broken.answer), "Half"); |
| 192 | const failed = new AnswerReader(); |
| 193 | failed.feed('{"type":"error","message":"Anthropic said the key is invalid."}'); |
| 194 | failed.finish(); |
| 195 | assert.equal(failed.answer.state, "error"); |
| 196 | assert.equal(finalBody(failed.body, failed.answer), "I couldn't answer that: Anthropic said the key is invalid."); |
| 197 | const stopped = new AnswerReader(); |
| 198 | stopped.stop(); |
| 199 | assert.equal(finalBody(stopped.body, stopped.answer), "_Stopped before answering._"); |
| 200 | }); |
| 201 | |
| 202 | test("one person's answers are limited to twenty a minute", () => { |
| 203 | let times: number[] = []; |
| 204 | for (let i = 0; i < 20; i++) { |
| 205 | const next = answerAllowed(times, 1_000 + i); |
| 206 | assert.ok(next.ok); |
| 207 | times = next.times; |
| 208 | } |
| 209 | assert.ok(!answerAllowed(times, 2_000).ok); |
| 210 | assert.ok(answerAllowed(times, 70_000).ok); |
| 211 | }); |
| 212 | |
| 213 | test("an answer left mid-stream by a room that went away shows as stopped", () => { |
| 214 | const at = "2026-10-12T00:00:00Z"; |
| 215 | assert.equal(shownAnswer({ state: "streaming" }, at, Date.parse(at) + 60_000)!.state, "streaming"); |
| 216 | assert.equal(shownAnswer({ state: "streaming" }, at, Date.parse(at) + 3_600_000)!.state, "stopped"); |
| 217 | assert.equal(shownAnswer({ state: "done" }, at, Date.parse(at) + 3_600_000)!.state, "done"); |
| 218 | assert.equal(shownAnswer(undefined, at, 0), null); |
| 219 | }); |