Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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