Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so. | 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | import { DatabaseSync } from "node:sqlite"; | |
| 4 | import { test } from "node:test"; | |
| 5 | ||
| 6 | import type { User } from "@g1t/contracts"; | |
| 7 | ||
| 8 | import { DESTROYED, destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts"; | |
| 9 | ||
| 10 | /** D1, as far as archived agents use it, over node's SQLite with the service's migrations: statements, `first`, `all`, `run` and `batch`. */ | |
| 11 | function fakeD1(): D1Database & { raw: DatabaseSync } { | |
| 12 | const db = new DatabaseSync(":memory:"); | |
| 13 | const dir = new URL("../migrations/", import.meta.url); | |
| 14 | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 15 | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 16 | bind: (...values: unknown[]) => statement(sql, values), | |
| 17 | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 18 | run: async () => { | |
| 19 | const r = db.prepare(sql).run(...(params as never[])); | |
| 20 | return { meta: { changes: Number(r.changes) } }; | |
| 21 | }, | |
| 22 | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 23 | }); | |
| 24 | return { prepare: (sql: string) => statement(sql), batch: async (statements: { run: () => Promise<unknown> }[]) => Promise.all(statements.map((s) => s.run())), raw: db } as unknown as D1Database & { raw: DatabaseSync }; | |
| 25 | } | |
| 26 | ||
| 27 | const WS = "wsp_acme"; | |
| 28 | const owner = { id: "u_owner", kind: "user", username: "ana", verified: true, workspaces: [{ slug: "acme", role: "owner" }] } as unknown as User; | |
| 29 | const member = { id: "u_mem", kind: "user", username: "mike", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; | |
| 30 | const other = { id: "u_other", kind: "user", username: "sam", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; | |
| 31 | ||
| 32 | type Seed = { id: string; handle: string; archived_at?: string | null; archived_by?: string | null; scope?: string; owner_id?: string | null; owner_username?: string | null; builtin?: number }; | |
| 33 | ||
| 34 | /** An agent row with what the queries read; everything else is the default. */ | |
| 35 | function seed(db: { raw: DatabaseSync }, a: Seed): void { | |
| 36 | db.raw | |
| 37 | .prepare( | |
| 38 | `INSERT INTO agents (id, workspace_id, handle, display_name, avatar, role, instructions, routing, budget, autonomy, title, created_by, created_at, updated_at, archived_at, archived_by, scope, owner_id, owner_username, builtin) | |
| 39 | VALUES (?, ?, ?, ?, NULL, 'Helps', 'Help.', '{}', '{}', '{}', 'Helper', 'ana', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?, ?, ?, ?, ?, ?)`, | |
| 40 | ) | |
| 41 | .run(a.id, WS, a.handle, a.handle[0]!.toUpperCase() + a.handle.slice(1), a.archived_at ?? null, a.archived_by ?? null, a.scope ?? "workspace", a.owner_id ?? null, a.owner_username ?? null, a.builtin ?? 0); | |
| 42 | } | |
| 43 | ||
| 44 | /** Rows about one agent across the tables a delete reaches, so the test sees them go. */ | |
| 45 | function seedHistory(db: { raw: DatabaseSync }, agentId: string, n: number): void { | |
| 46 | const r = db.raw; | |
| 47 | r.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, 1, '{}', 'ana', '2026-10-01T00:00:00Z')").run(agentId); | |
| 48 | for (let i = 0; i < n; i++) { | |
| 49 | r.prepare( | |
| 50 | `INSERT INTO agent_sessions (id, workspace_id, agent_id, kind, root_id, payer_agent_id, title, goal, status, workspace, channel_id, channel_kind, created_at, updated_at) | |
| 51 | VALUES (?1, ?2, ?3, 'chat', ?1, ?3, 'Did a thing', 'Do the thing', 'done', 'acme', 'chn_1', 'dm', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 52 | ).run(`ses_${agentId}_${i}`, WS, agentId); | |
| 53 | r.prepare("INSERT INTO agent_session_events (session_id, seq, kind, body, created_at) VALUES (?, 1, 'asked', 'do it', '2026-10-02T00:00:00Z')").run(`ses_${agentId}_${i}`); | |
| 54 | r.prepare( | |
| 55 | `INSERT INTO agent_replies (id, agent_id, workspace_id, channel_id, message_id, status, created_at) | |
| 56 | VALUES (?, ?, ?, 'chn_1', ?, 'done', '2026-10-02T00:00:00Z')`, | |
| 57 | ).run(`rep_${agentId}_${i}`, agentId, WS, `msg_${agentId}_${i}`); | |
| 58 | } | |
| 59 | r.prepare( | |
| 60 | `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, body, source_kind, created_by, created_by_kind, created_at, updated_at) | |
| 61 | VALUES (?, ?, ?, 'workspace', '', 'Deploys are on Tuesdays', 'chat', 'ana', 'user', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 62 | ).run(`mem_${agentId}`, agentId, WS); | |
| 63 | r.prepare( | |
| 64 | `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, channel_id, sponsor, enabled, created_at, updated_at) | |
| 65 | VALUES (?, ?, ?, 'acme', 'Standup', 'Post the standup', 'chn_1', 'u_owner', 1, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 66 | ).run(`rtn_${agentId}`, agentId, WS); | |
| 67 | r.prepare("INSERT INTO agent_routine_runs (routine_id, run_key, created_at) VALUES (?, 'day:2026-10-03', '2026-10-03T00:00:00Z')").run(`rtn_${agentId}`); | |
| 68 | r.prepare("INSERT INTO agent_spend (agent_id, period, micros, replies) VALUES (?, '2026-10', 1200000, 3)").run(agentId); | |
| 69 | r.prepare("INSERT OR IGNORE INTO workspace_agent_spend (workspace_id, period, micros) VALUES (?, '2026-10', 1200000)").run(WS); | |
| 70 | r.prepare("INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, 'skl_1', 'agent', ?, 1, 'ana', '2026-10-02T00:00:00Z')").run(`att_${agentId}`, WS, agentId); | |
| 71 | } | |
| 72 | ||
| 73 | const count = (db: { raw: DatabaseSync }, table: string, where: string, ...params: unknown[]) => Number((db.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...(params as never[])) as { n: number }).n); | |
| 74 | ||
| 75 | test("the archived list has archived agents only, newest archived first, with what deleting each would take", async () => { | |
| 76 | const db = fakeD1(); | |
| 77 | seed(db, { id: "agt_live", handle: "live" }); | |
| 78 | seed(db, { id: "agt_old", handle: "old", archived_at: "2026-09-01T00:00:00Z", archived_by: "ana" }); | |
| 79 | seed(db, { id: "agt_new", handle: "new", archived_at: "2026-10-05T00:00:00Z", archived_by: "mike" }); | |
| 80 | seed(db, { id: "agt_g1t", handle: "g1t", builtin: 1 }); | |
| 81 | seedHistory(db, "agt_new", 2); | |
| 82 | const listed = await listArchived(db, WS, { id: owner.id, owner: true }); | |
| 83 | assert.deepEqual( | |
| 84 | listed.map((a) => [a.handle, a.archived_by, a.counts]), | |
| 85 | [ | |
| 86 | ["new", "mike", { sessions: 2, replies: 2, memories: 1, routines: 1 }], | |
| 87 | ["old", "ana", { sessions: 0, replies: 0, memories: 0, routines: 0 }], | |
| 88 | ], | |
| 89 | ); | |
| 90 | assert.equal(listed[0]!.display_name, "New"); | |
| 91 | assert.equal(listed[0]!.avatar_seed, "new"); | |
| 92 | assert.deepEqual(await listArchived(db, "wsp_other", { id: owner.id, owner: true }), []); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a member sees only their own archived personal agents", async () => { | |
| 96 | const db = fakeD1(); | |
| 97 | seed(db, { id: "agt_ws", handle: "ws", archived_at: "2026-10-01T00:00:00Z" }); | |
| 98 | seed(db, { id: "agt_mine", handle: "mine", archived_at: "2026-10-02T00:00:00Z", scope: "personal", owner_id: member.id, owner_username: "mike" }); | |
| 99 | seed(db, { id: "agt_theirs", handle: "theirs", archived_at: "2026-10-03T00:00:00Z", scope: "personal", owner_id: other.id, owner_username: "sam" }); | |
| 100 | const mine = await listArchived(db, WS, { id: member.id, owner: false }); | |
| 101 | assert.deepEqual( | |
| 102 | mine.map((a) => [a.handle, a.scope, a.personal_owner]), | |
| 103 | [["mine", "personal", "mike"]], | |
| 104 | ); | |
| 105 | // Owners see every member's, and the workspace's. | |
| 106 | assert.deepEqual((await listArchived(db, WS, { id: owner.id, owner: true })).map((a) => a.handle), ["theirs", "mine", "ws"]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("restoring: owners any agent, a member their own personal one, and only an archived one", () => { | |
| 110 | const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" }; | |
| 111 | assert.equal(restoreRefusal(owner, "acme", archived), null); | |
| 112 | assert.equal(restoreRefusal(member, "acme", archived)?.code, "forbidden"); | |
| 113 | const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id }; | |
| 114 | assert.equal(restoreRefusal(member, "acme", personal), null); | |
| 115 | assert.equal(restoreRefusal(other, "acme", personal)?.code, "forbidden"); | |
| 116 | assert.equal(restoreRefusal(owner, "acme", { ...archived, archived_at: null })?.code, "invalid"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("restoring under a handle a live agent holds is a conflict that says what to do; a new handle is checked", () => { | |
| 120 | const agent = { handle: "sam", archived_at: "2026-10-01T00:00:00Z" }; | |
| 121 | const taken = (h: string) => h === "sam" || h === "ana"; | |
| 122 | const conflict = restoreHandle(agent, null, taken); | |
| 123 | assert.equal(!conflict.ok && conflict.error.code, "conflict"); | |
| 124 | assert.match(!conflict.ok ? conflict.error.message : "", /Rename that one first, or restore this one under a new handle/); | |
| 125 | const again = restoreHandle(agent, "ana", taken); | |
| 126 | assert.match(!again.ok ? again.error.message : "", /already called @ana. Pick another/); | |
| 127 | assert.deepEqual(restoreHandle(agent, "@Sam-2", taken), { ok: true, handle: "sam-2" }); | |
| 128 | assert.deepEqual(restoreHandle(agent, "", () => false), { ok: true, handle: "sam" }); | |
| 129 | const bad = restoreHandle(agent, "a", taken); | |
| 130 | assert.equal(!bad.ok && bad.error.code, "invalid"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("deleting refuses a live agent, the built-in @g1t, the wrong person and a confirmation that is not the handle", () => { | |
| 134 | const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" }; | |
| 135 | assert.equal(destroyRefusal(owner, "acme", archived, "sam"), null); | |
| 136 | assert.equal(destroyRefusal(owner, "acme", archived, "@Sam "), null); | |
| 137 | assert.match(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.message ?? "", /Archive @sam first/); | |
| 138 | assert.equal(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.code, "invalid"); | |
| 139 | assert.match(destroyRefusal(owner, "acme", { ...archived, handle: "g1t", builtin: 1 }, "g1t")?.message ?? "", /can't be deleted/); | |
| 140 | assert.equal(destroyRefusal(member, "acme", archived, "sam")?.code, "forbidden"); | |
| 141 | assert.equal(destroyRefusal(owner, "acme", archived, "same")?.code, "invalid"); | |
| 142 | assert.equal(destroyRefusal(owner, "acme", archived, undefined)?.code, "invalid"); | |
| 143 | const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id }; | |
| 144 | assert.equal(destroyRefusal(member, "acme", personal, "me"), null); | |
| 145 | assert.equal(destroyRefusal(other, "acme", personal, "me")?.code, "forbidden"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("deleting removes every row about the agent, and nothing of another's or of the workspace's totals", async () => { | |
| 149 | const db = fakeD1(); | |
| 150 | seed(db, { id: "agt_gone", handle: "gone", archived_at: "2026-10-01T00:00:00Z" }); | |
| 151 | seed(db, { id: "agt_kept", handle: "kept" }); | |
| 152 | seedHistory(db, "agt_gone", 3); | |
| 153 | seedHistory(db, "agt_kept", 1); | |
| 154 | const results = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[]; | |
| 155 | assert.equal(results.length, DESTROYED.length); | |
| 156 | assert.equal(results[results.length - 1]!.meta.changes, 1, "the agents row went last"); | |
| 157 | for (const { table } of DESTROYED) { | |
| 158 | const column = table === "agents" ? "id" : table === "skill_attachments" ? "target" : table === "agent_session_events" ? "session_id" : table === "agent_routine_runs" ? "routine_id" : "agent_id"; | |
| 159 | const pattern = table === "agent_session_events" ? "ses_agt_gone_%" : table === "agent_routine_runs" ? "rtn_agt_gone" : "agt_gone"; | |
| 160 | assert.equal(count(db, table, `${column} LIKE ?`, pattern), 0, `${table} still has rows about the deleted agent`); | |
| 161 | } | |
| 162 | // The other agent is untouched, and the workspace's month total stays for the statement. | |
| 163 | assert.equal(count(db, "agents", "id = ?", "agt_kept"), 1); | |
| 164 | assert.equal(count(db, "agent_sessions", "agent_id = ?", "agt_kept"), 1); | |
| 165 | assert.equal(count(db, "agent_session_events", "session_id LIKE ?", "ses_agt_kept_%"), 1); | |
| 166 | assert.equal(count(db, "agent_replies", "agent_id = ?", "agt_kept"), 1); | |
| 167 | assert.equal(count(db, "agent_memories", "agent_id = ?", "agt_kept"), 1); | |
| 168 | assert.equal(count(db, "agent_routine_runs", "routine_id = ?", "rtn_agt_kept"), 1); | |
| 169 | assert.equal(count(db, "skill_attachments", "target = ?", "agt_kept"), 1); | |
| 170 | assert.equal(count(db, "workspace_agent_spend", "workspace_id = ?", WS), 1); | |
| 171 | // A second delete finds nothing: the agents row is gone. | |
| 172 | const again = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[]; | |
| 173 | assert.equal(again[again.length - 1]!.meta.changes, 0); | |
| 174 | }); | |
| 175 | ||
| 176 | test("deleting leaves a live agent's row alone even when asked by id", async () => { | |
| 177 | const db = fakeD1(); | |
| 178 | seed(db, { id: "agt_live", handle: "live" }); | |
| 179 | const results = (await db.batch(destroyStatements(db, "agt_live"))) as { meta: { changes: number } }[]; | |
| 180 | assert.equal(results[results.length - 1]!.meta.changes, 0); | |
| 181 | assert.equal(count(db, "agents", "id = ?", "agt_live"), 1); | |
| 182 | }); |