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.

g1t.sh
160 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync, readdirSync } from "node:fs";
3import { DatabaseSync } from "node:sqlite";
4import { test } from "node:test";
5
6import { G1T_KEY } from "@g1t/contracts/chat";
7
8import { clearConversation, mayClear } from "./clear.ts";
9import { conversation, latestQuestion, type SaidRow } from "./g1t.ts";
10
11/** D1, as far as a clear uses it, over node's SQLite with chat's migrations. */
12function fakeD1(): D1Database & { raw: DatabaseSync } {
13 const db = new DatabaseSync(":memory:");
14 const dir = new URL("../migrations/", import.meta.url);
15 for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
16 type Statement = { sql: string; params: unknown[] };
17 const run = (s: Statement) => {
18 const done = db.prepare(s.sql).run(...(s.params as never[]));
19 return { success: true, meta: { changes: Number(done.changes) }, results: [] };
20 };
21 const statement = (sql: string, params: unknown[] = []): Statement & Record<string, unknown> => ({
22 sql,
23 params,
24 bind: (...values: unknown[]) => statement(sql, values),
25 first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
26 run: async () => run({ sql, params }),
27 all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
28 });
29 return {
30 raw: db,
31 prepare: (sql: string) => statement(sql),
32 // One transaction, as D1's batch is.
33 batch: async (statements: Statement[]) => {
34 db.exec("BEGIN");
35 try {
36 const results = statements.map(run);
37 db.exec("COMMIT");
38 return results;
39 } catch (error) {
40 db.exec("ROLLBACK");
41 throw error;
42 }
43 },
44 } as unknown as D1Database & { raw: DatabaseSync };
45}
46
47const AT = "2026-10-14T12:00:00.000Z";
48const ANA = "user:u_ana";
49const BEN = "user:u_ben";
50
51function channel(db: DatabaseSync, id: string, kind: "channel" | "dm", members: string[]) {
52 db.prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at, last_message_at) VALUES (?, 'wsp_1', ?, ?, ?, ?, ?)").run(
53 id,
54 kind,
55 kind === "channel" ? id : null,
56 members[0]!,
57 AT,
58 AT,
59 );
60 for (const principal of members) {
61 db.prepare("INSERT INTO channel_members (channel_id, principal, last_read_id, joined_at) VALUES (?, ?, 'msg_9', ?)").run(id, principal, AT);
62 }
63}
64
65function say(db: DatabaseSync, channelId: string, id: string, author: string, body: string, extra: { meta?: string; thread_root?: string; deleted?: boolean } = {}) {
66 db.prepare("INSERT INTO messages (id, channel_id, author, body, thread_root, created_at, deleted_at, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
67 id,
68 channelId,
69 author,
70 body,
71 extra.thread_root ?? null,
72 AT,
73 extra.deleted ? AT : null,
74 extra.meta ?? null,
75 );
76}
77
78const answer = (text: string) => JSON.stringify({ answer: { state: "done", steps: [{ type: "tool", id: "t1", label: "Read pull request #7", status: "done", summary: text }] } });
79
80/** Ana's direct message with g1t, Ben's, and a channel they share. */
81function workspace() {
82 const d1 = fakeD1();
83 const db = d1.raw;
84 channel(db, "chn_ana_g1t", "dm", [ANA, G1T_KEY]);
85 channel(db, "chn_ben_g1t", "dm", [BEN, G1T_KEY]);
86 channel(db, "chn_general", "channel", [ANA, BEN]);
87 say(db, "chn_ana_g1t", "msg_01", ANA, "What is the launch code word?", { meta: JSON.stringify({ page: { path: "/acme/web/pull/7" } }) });
88 say(db, "chn_ana_g1t", "msg_02", G1T_KEY, "It is swordfish.", { meta: answer("swordfish") });
89 say(db, "chn_ana_g1t", "msg_03", ANA, "a reply in a thread", { thread_root: "msg_02" });
90 say(db, "chn_ana_g1t", "msg_04", G1T_KEY, "New conversation", { meta: JSON.stringify({ reset: true }) });
91 say(db, "chn_ana_g1t", "msg_05", ANA, "", { deleted: true });
92 db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_02', ?, 'thumbsup', ?)").run(ANA, AT);
93 say(db, "chn_ben_g1t", "msg_11", BEN, "Ben's own question");
94 say(db, "chn_ben_g1t", "msg_12", G1T_KEY, "Ben's answer", { meta: answer("ben") });
95 say(db, "chn_general", "msg_21", ANA, "hello team");
96 db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_21', ?, 'wave', ?)").run(BEN, AT);
97 db.prepare("INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES ('wsp_1', '2026-10-14', 8, 120)").run();
98 return d1;
99}
100
101const count = (db: DatabaseSync, sql: string, ...params: string[]) => Number((db.prepare(sql).get(...params) as { n: number }).n);
102
103test("only the one person in a direct message with g1t may clear it, and only their own", () => {
104 assert.ok(mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: ANA }));
105 // Someone else's direct message with g1t, whoever asks.
106 assert.ok(!mayClear({ channelKind: "dm", members: [BEN, G1T_KEY], viewer: ANA }));
107 // A channel, a direct message between people, a group with g1t in it.
108 assert.ok(!mayClear({ channelKind: "channel", members: [ANA, G1T_KEY], viewer: ANA }));
109 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN], viewer: ANA }));
110 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN, G1T_KEY], viewer: ANA }));
111 // g1t itself is not a person who clears.
112 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: G1T_KEY }));
113});
114
115test("clearing deletes everything in that conversation and nothing anywhere else", async () => {
116 const d1 = workspace();
117 const db = d1.raw;
118 const done = await clearConversation(d1, "chn_ana_g1t");
119 // Every row, before and after the mark, thread replies and deleted ones too.
120 assert.equal(done.messages, 5);
121 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ana_g1t"), 0);
122 assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_02'"), 0);
123 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE body LIKE '%swordfish%' OR meta LIKE '%swordfish%'"), 0);
124 // The direct message stays, empty, with its two members and nothing read or said last.
125 const kept = db.prepare("SELECT last_message_at FROM channels WHERE id = 'chn_ana_g1t'").get() as { last_message_at: string | null };
126 assert.equal(kept.last_message_at, null);
127 assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ana_g1t' AND last_read_id IS NULL"), 2);
128 // Ben's conversation with g1t, the shared channel and its reactions, and the meter are untouched.
129 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ben_g1t"), 2);
130 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_general"), 1);
131 assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_21'"), 1);
132 assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ben_g1t' AND last_read_id = 'msg_9'"), 2);
133 assert.equal(count(db, "SELECT messages AS n FROM chat_meter WHERE workspace_id = 'wsp_1'"), 8);
134});
135
136test("clearing again, or an empty conversation, deletes nothing and is no error", async () => {
137 const d1 = workspace();
138 assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 5);
139 assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 0);
140 assert.equal((await clearConversation(d1, "chn_nobody")).messages, 0);
141 assert.equal(count(d1.raw, "SELECT COUNT(*) AS n FROM messages"), 3);
142});
143
144test("after a clear, what g1t reads holds none of the old conversation", async () => {
145 const d1 = workspace();
146 const db = d1.raw;
147 const read = () => db.prepare("SELECT id, author, kind, body, meta, deleted_at FROM messages WHERE channel_id = 'chn_ana_g1t' AND thread_root IS NULL ORDER BY id").all() as SaidRow[];
148 // Before: a question after the mark would still read nothing of before it, but the history is there.
149 say(db, "chn_ana_g1t", "msg_06", ANA, "And before that?");
150 assert.ok(read().some((row) => row.body.includes("swordfish")));
151 await clearConversation(d1, "chn_ana_g1t");
152 assert.deepEqual(conversation(read()), []);
153 assert.equal(latestQuestion(read()), null);
154 // The next question is read on its own.
155 say(db, "chn_ana_g1t", "msg_31", ANA, "What did I ask you before?");
156 const asked = conversation(read());
157 assert.deepEqual(asked, [{ role: "user", content: "What did I ask you before?" }]);
158 assert.ok(!JSON.stringify(asked).includes("swordfish"));
159 assert.equal(latestQuestion(read()), "msg_31");
160});