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