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.

171 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(
66 db: DatabaseSync,
67 channelId: string,
68 id: string,
69 author: string,
70 body: string,
71 extra: { meta?: string; thread_root?: string; deleted?: boolean } = {},
72) {
73 db.prepare("INSERT INTO messages (id, channel_id, author, body, thread_root, created_at, deleted_at, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
74 id,
75 channelId,
76 author,
77 body,
78 extra.thread_root ?? null,
79 AT,
80 extra.deleted ? AT : null,
81 extra.meta ?? null,
82 );
83}
84
85const answer = (text: string) =>
86 JSON.stringify({ answer: { state: "done", steps: [{ type: "tool", id: "t1", label: "Read pull request #7", status: "done", summary: text }] } });
87
88/** Ana's direct message with g1t, Ben's, and a channel they share. */
89function workspace() {
90 const d1 = fakeD1();
91 const db = d1.raw;
92 channel(db, "chn_ana_g1t", "dm", [ANA, G1T_KEY]);
93 channel(db, "chn_ben_g1t", "dm", [BEN, G1T_KEY]);
94 channel(db, "chn_general", "channel", [ANA, BEN]);
95 say(db, "chn_ana_g1t", "msg_01", ANA, "What is the launch code word?", { meta: JSON.stringify({ page: { path: "/acme/web/pull/7" } }) });
96 say(db, "chn_ana_g1t", "msg_02", G1T_KEY, "It is swordfish.", { meta: answer("swordfish") });
97 say(db, "chn_ana_g1t", "msg_03", ANA, "a reply in a thread", { thread_root: "msg_02" });
98 say(db, "chn_ana_g1t", "msg_04", G1T_KEY, "New conversation", { meta: JSON.stringify({ reset: true }) });
99 say(db, "chn_ana_g1t", "msg_05", ANA, "", { deleted: true });
100 db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_02', ?, 'thumbsup', ?)").run(ANA, AT);
101 say(db, "chn_ben_g1t", "msg_11", BEN, "Ben's own question");
102 say(db, "chn_ben_g1t", "msg_12", G1T_KEY, "Ben's answer", { meta: answer("ben") });
103 say(db, "chn_general", "msg_21", ANA, "hello team");
104 db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_21', ?, 'wave', ?)").run(BEN, AT);
105 db.prepare("INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES ('wsp_1', '2026-10-14', 8, 120)").run();
106 return d1;
107}
108
109const count = (db: DatabaseSync, sql: string, ...params: string[]) => Number((db.prepare(sql).get(...params) as { n: number }).n);
110
111test("only the one person in a direct message with g1t may clear it, and only their own", () => {
112 assert.ok(mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: ANA }));
113 // Someone else's direct message with g1t, whoever asks.
114 assert.ok(!mayClear({ channelKind: "dm", members: [BEN, G1T_KEY], viewer: ANA }));
115 // A channel, a direct message between people, a group with g1t in it.
116 assert.ok(!mayClear({ channelKind: "channel", members: [ANA, G1T_KEY], viewer: ANA }));
117 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN], viewer: ANA }));
118 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN, G1T_KEY], viewer: ANA }));
119 // g1t itself is not a person who clears.
120 assert.ok(!mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: G1T_KEY }));
121});
122
123test("clearing deletes everything in that conversation and nothing anywhere else", async () => {
124 const d1 = workspace();
125 const db = d1.raw;
126 const done = await clearConversation(d1, "chn_ana_g1t");
127 // Every row, before and after the mark, thread replies and deleted ones too.
128 assert.equal(done.messages, 5);
129 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ana_g1t"), 0);
130 assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_02'"), 0);
131 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE body LIKE '%swordfish%' OR meta LIKE '%swordfish%'"), 0);
132 // The direct message stays, empty, with its two members and nothing read or said last.
133 const kept = db.prepare("SELECT last_message_at FROM channels WHERE id = 'chn_ana_g1t'").get() as { last_message_at: string | null };
134 assert.equal(kept.last_message_at, null);
135 assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ana_g1t' AND last_read_id IS NULL"), 2);
136 // Ben's conversation with g1t, the shared channel and its reactions, and the meter are untouched.
137 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ben_g1t"), 2);
138 assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_general"), 1);
139 assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_21'"), 1);
140 assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ben_g1t' AND last_read_id = 'msg_9'"), 2);
141 assert.equal(count(db, "SELECT messages AS n FROM chat_meter WHERE workspace_id = 'wsp_1'"), 8);
142});
143
144test("clearing again, or an empty conversation, deletes nothing and is no error", async () => {
145 const d1 = workspace();
146 assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 5);
147 assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 0);
148 assert.equal((await clearConversation(d1, "chn_nobody")).messages, 0);
149 assert.equal(count(d1.raw, "SELECT COUNT(*) AS n FROM messages"), 3);
150});
151
152test("after a clear, what g1t reads holds none of the old conversation", async () => {
153 const d1 = workspace();
154 const db = d1.raw;
155 const read = () =>
156 db
157 .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")
158 .all() as SaidRow[];
159 // Before: a question after the mark would still read nothing of before it, but the history is there.
160 say(db, "chn_ana_g1t", "msg_06", ANA, "And before that?");
161 assert.ok(read().some((row) => row.body.includes("swordfish")));
162 await clearConversation(d1, "chn_ana_g1t");
163 assert.deepEqual(conversation(read()), []);
164 assert.equal(latestQuestion(read()), null);
165 // The next question is read on its own.
166 say(db, "chn_ana_g1t", "msg_31", ANA, "What did I ask you before?");
167 const asked = conversation(read());
168 assert.deepEqual(asked, [{ role: "user", content: "What did I ask you before?" }]);
169 assert.ok(!JSON.stringify(asked).includes("swordfish"));
170 assert.equal(latestQuestion(read()), "msg_31");
171});