Skip to content
219 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import { G1T_KEY, isG1t, parsePrincipalKey } from "@g1t/contracts/chat";
5import { ASK_MAX_MESSAGE_CHARS, ASK_MAX_TOTAL_CHARS } from "@g1t/contracts/integrations";
6
7import { dmKey, dmMembers } from "./names.ts";
8import {
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
23const ana = "user:u1";
24const bo = "user:u2";
25
26test("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
33test("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
44test("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
57const 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});
66const done = { answer: { state: "done" } };
67
68test("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
82test("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
95test("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
100test("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
111test("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
128test("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
148test("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
155test("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
167test("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
186test("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
202test("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
213test("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});