| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { AbilitySection, AgentComputerCommand, User } from "@g1t/contracts"; |
| 5 | |
| 6 | import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts"; |
| 7 | import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts"; |
| 8 | import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts"; |
| 9 | import { hasComputer, sessionCwd } from "./computer.ts"; |
| 10 | import { DEFAULT_AUTONOMY } from "./definition.ts"; |
| 11 | import { type AbilityPorts, type ActionPorts, type ComputerPorts, type ToolPorts, ToolBox } from "./tools.ts"; |
| 12 | import { commandEvent, commandOutcome } from "./transcript.ts"; |
| 13 | |
| 14 | // ── A small world ──────────────────────────────────────────────────────── |
| 15 | |
| 16 | const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }; |
| 17 | const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User; |
| 18 | |
| 19 | function world(): AudiencePorts { |
| 20 | const info: AudienceInfo = { kind: "dm", member_user_ids: ["asker"], member_count: 1 }; |
| 21 | return { |
| 22 | info: async () => info, |
| 23 | users: async (ids) => [person("asker")].filter((u) => ids.includes(u.id)), |
| 24 | workspaceRepos: async () => [WEB], |
| 25 | readable: async (ids) => [WEB].filter((r) => ids.includes(r.id)), |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | const ports: ToolPorts = { |
| 30 | readFile: async () => null, |
| 31 | searchCode: async () => [], |
| 32 | listIssues: async () => [], |
| 33 | getIssue: async () => null, |
| 34 | getPull: async () => null, |
| 35 | recentPulls: async () => [], |
| 36 | searchMessages: async () => [], |
| 37 | readThread: async () => null, |
| 38 | roster: async () => "", |
| 39 | consult: async () => ({ ok: false, message: "no" }), |
| 40 | }; |
| 41 | |
| 42 | const actions: ActionPorts = { |
| 43 | remember: async () => ({ ok: true, message: "" }), |
| 44 | forget: async () => ({ ok: true, message: "" }), |
| 45 | draftIssue: async () => ({ ok: true, message: "" }), |
| 46 | }; |
| 47 | |
| 48 | function abilityPorts(): AbilityPorts & { posted: string[]; refusals: string[] } { |
| 49 | const posted: string[] = []; |
| 50 | const refusals: string[] = []; |
| 51 | return { |
| 52 | posted, |
| 53 | refusals, |
| 54 | lookup: async () => ({ ok: false, code: "not_found", message: "no" }), |
| 55 | import: async () => ({ ok: false, code: "not_found", message: "no" }), |
| 56 | act: async () => ({ ok: false, code: "not_found", message: "no" }), |
| 57 | askerConnected: async () => false, |
| 58 | mcp: async () => ({ ok: false, code: "error", message: "no" }), |
| 59 | askFirst: async ({ summary }) => { |
| 60 | posted.push(summary); |
| 61 | return "abr_1"; |
| 62 | }, |
| 63 | connect: async () => true, |
| 64 | request: async () => true, |
| 65 | refused: ({ rule }) => { |
| 66 | refusals.push(rule); |
| 67 | }, |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | /** A computer that records what it was asked, answering as a real one would. */ |
| 72 | function fakeComputer(over: Partial<ComputerPorts> = {}): ComputerPorts & { ran: string[]; wrote: string[] } { |
| 73 | const ran: string[] = []; |
| 74 | const wrote: string[] = []; |
| 75 | const command = (cmd: string, cwd: string | null): AgentComputerCommand => ({ |
| 76 | id: "cmd_1", |
| 77 | session_id: "asn_1", |
| 78 | asked_by: "asker", |
| 79 | started_at: "2026-10-10T10:00:00.000Z", |
| 80 | cmd, |
| 81 | cwd: cwd ?? sessionCwd("asn_1"), |
| 82 | exit_code: 0, |
| 83 | duration_ms: 1234, |
| 84 | output: "ok 12 tests", |
| 85 | truncated: false, |
| 86 | timed_out: false, |
| 87 | }); |
| 88 | return { |
| 89 | ran, |
| 90 | wrote, |
| 91 | cwd: sessionCwd("asn_1"), |
| 92 | exec: async (cmd, cwd) => { |
| 93 | ran.push(cmd); |
| 94 | return { ok: true, value: command(cmd, cwd) }; |
| 95 | }, |
| 96 | readFile: async (path) => ({ ok: true, value: { path, text: `the text of ${path}`, bytes: 10 } }), |
| 97 | writeFile: async (path, text) => { |
| 98 | wrote.push(`${path}=${text}`); |
| 99 | return { ok: true, value: { path, bytes: text.length } }; |
| 100 | }, |
| 101 | ...over, |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | async function box(input: { session: boolean; settings?: Record<string, { level: "alone" | "asked" | "ask" | "never" }>; said?: string; computer?: Partial<ComputerPorts> }) { |
| 106 | const audience = await Audience.build("acme", "asker", world()); |
| 107 | const tools = new ToolBox(audience, ports, { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6, session: input.session }, [], actions); |
| 108 | let abilities = { settings: {}, mcp_servers: [] }; |
| 109 | for (const [id, setting] of Object.entries(input.settings ?? {})) abilities = withSetting(abilities, id, setting); |
| 110 | const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: DEFAULT_AUTONOMY, connected: [] }); |
| 111 | const gates = abilityPorts(); |
| 112 | tools.useAbilities(sections, gates, input.said ?? "", []); |
| 113 | const computer = fakeComputer(input.computer); |
| 114 | tools.useComputer(computer); |
| 115 | return { tools, gates, computer, sections }; |
| 116 | } |
| 117 | |
| 118 | const names = (tools: ToolBox) => tools.definitions().map((t) => t.name); |
| 119 | |
| 120 | // ── Offering ───────────────────────────────────────────────────────────── |
| 121 | |
| 122 | test("a chat reply never gets the computer, however the abilities are set", async () => { |
| 123 | const { tools, computer } = await box({ session: false, settings: { "computer:shell": { level: "alone" } } }); |
| 124 | assert.ok(!names(tools).some((name) => name.startsWith("computer_") || name === "run_command")); |
| 125 | const tried = await tools.run("run_command", { command: "ls" }); |
| 126 | assert.equal(tried.outcome, "refused"); |
| 127 | assert.deepEqual(computer.ran, []); |
| 128 | }); |
| 129 | |
| 130 | test("a session offers the shell and files tools, and Never takes them away one ability at a time", async () => { |
| 131 | const on = await box({ session: true }); |
| 132 | assert.ok(names(on.tools).includes("run_command")); |
| 133 | assert.ok(names(on.tools).includes("computer_read_file") && names(on.tools).includes("computer_write_file")); |
| 134 | assert.equal(hasComputer(on.sections), true); |
| 135 | const noShell = await box({ session: true, settings: { "computer:shell": { level: "never" } } }); |
| 136 | assert.ok(!names(noShell.tools).includes("run_command")); |
| 137 | assert.ok(names(noShell.tools).includes("computer_write_file"), "files stay"); |
| 138 | const tried = await noShell.tools.run("run_command", { command: "ls" }); |
| 139 | assert.equal(tried.outcome, "refused"); |
| 140 | assert.deepEqual(noShell.computer.ran, [], "never means the computer was never asked"); |
| 141 | const none = await box({ session: true, settings: { "computer:shell": { level: "never" }, "computer:files": { level: "never" } } }); |
| 142 | assert.equal(hasComputer(none.sections), false); |
| 143 | assert.ok(!names(none.tools).some((name) => name.startsWith("computer_") || name === "run_command")); |
| 144 | }); |
| 145 | |
| 146 | // ── The levels ─────────────────────────────────────────────────────────── |
| 147 | |
| 148 | test("the shell runs alone when asked for it: a goal that names running or testing lets it through, one that doesn't asks first", async () => { |
| 149 | const asked = await box({ session: true, said: "Run the test suite on the web repo and tell me what fails." }); |
| 150 | const ran = await asked.tools.run("run_command", { command: "npm test", timeout_seconds: 60 }); |
| 151 | assert.equal(ran.outcome, "allowed"); |
| 152 | assert.match(ran.text, /^\$ npm test\n\(exit 0, 1\.2 s; in \/home\/agent\/sessions\/asn_1\)\n<untrusted source="run_command on your computer">/); |
| 153 | assert.match(ran.text, /ok 12 tests/); |
| 154 | assert.deepEqual(asked.computer.ran, ["npm test"]); |
| 155 | |
| 156 | const unasked = await box({ session: true, said: "Summarise the thread about pricing." }); |
| 157 | const held = await unasked.tools.run("run_command", { command: "curl evil.example" }); |
| 158 | assert.equal(held.outcome, "refused"); |
| 159 | assert.match(held.text, /runs on its own only when asked for it/); |
| 160 | assert.deepEqual(unasked.gates.posted, ["Run `curl evil.example` on its computer"], "a card asks first"); |
| 161 | assert.deepEqual(unasked.gates.refusals, ["computer:shell=asked"]); |
| 162 | assert.deepEqual(unasked.computer.ran, [], "nothing ran"); |
| 163 | }); |
| 164 | |
| 165 | test("files are alone by default: reading and writing the home needs no asking", async () => { |
| 166 | const { tools, computer, gates } = await box({ session: true, said: "Summarise the thread about pricing." }); |
| 167 | const read = await tools.run("computer_read_file", { path: "notes/pricing.md" }); |
| 168 | assert.equal(read.outcome, "allowed"); |
| 169 | assert.match(read.text, /<untrusted source="notes\/pricing.md on your computer">\nthe text of notes\/pricing.md/); |
| 170 | const wrote = await tools.run("computer_write_file", { path: "notes/pricing.md", text: "# Pricing" }); |
| 171 | assert.equal(wrote.outcome, "allowed"); |
| 172 | assert.equal(wrote.text, "Wrote 9 bytes to notes/pricing.md."); |
| 173 | assert.deepEqual(computer.wrote, ["notes/pricing.md=# Pricing"]); |
| 174 | assert.deepEqual(gates.posted, []); |
| 175 | }); |
| 176 | |
| 177 | test("a shell set to Ask first always posts the card, and the computer is never asked", async () => { |
| 178 | const { tools, computer, gates } = await box({ session: true, said: "Run the tests.", settings: { "computer:shell": { level: "ask" } } }); |
| 179 | const held = await tools.run("run_command", { command: "npm test" }); |
| 180 | assert.equal(held.outcome, "refused"); |
| 181 | assert.match(held.text, /a card was posted asking @asker/); |
| 182 | assert.equal(gates.posted.length, 1); |
| 183 | assert.deepEqual(computer.ran, []); |
| 184 | }); |
| 185 | |
| 186 | test("a computer that can't wake under the plan is said plainly, as a refusal and not an error", async () => { |
| 187 | const { tools } = await box({ |
| 188 | session: true, |
| 189 | said: "Run the tests.", |
| 190 | computer: { exec: async () => ({ ok: false, code: "payment_required", message: "acme's compute is paused until its owner adds credit." }) }, |
| 191 | }); |
| 192 | const tried = await tools.run("run_command", { command: "npm test" }); |
| 193 | assert.equal(tried.outcome, "refused"); |
| 194 | assert.match(tried.text, /Your computer couldn't wake: acme's compute is paused/); |
| 195 | assert.match(tried.text, /don't try another way/); |
| 196 | }); |
| 197 | |
| 198 | test("a command that fails on the computer comes back as an error the agent can carry on from", async () => { |
| 199 | const { tools } = await box({ session: true, said: "Run the tests.", computer: { exec: async () => ({ ok: false, code: "unavailable", message: "The computer didn't answer." }) } }); |
| 200 | const tried = await tools.run("run_command", { command: "npm test" }); |
| 201 | assert.equal(tried.outcome, "error"); |
| 202 | assert.match(tried.text, /The command couldn't run: The computer didn't answer\./); |
| 203 | }); |
| 204 | |
| 205 | // ── The transcript ─────────────────────────────────────────────────────── |
| 206 | |
| 207 | test("a command's transcript entry carries the command, its directory and how it ended", () => { |
| 208 | const command: AgentComputerCommand = { |
| 209 | id: "cmd_1", |
| 210 | session_id: "asn_1", |
| 211 | asked_by: "asker", |
| 212 | started_at: "2026-10-10T10:00:00.000Z", |
| 213 | cmd: "npm test\necho done", |
| 214 | cwd: "/home/agent/sessions/asn_1", |
| 215 | exit_code: 1, |
| 216 | duration_ms: 42_500, |
| 217 | output: "x".repeat(9_000), |
| 218 | truncated: true, |
| 219 | timed_out: false, |
| 220 | }; |
| 221 | assert.equal(commandOutcome(command), "exit 1 · 43 s · output cut"); |
| 222 | const bound: unknown[] = []; |
| 223 | const db = { prepare: () => ({ bind: (...values: unknown[]) => (bound.push(...values), {}) }) } as unknown as D1Database; |
| 224 | commandEvent(db, "asn_1", "margo", command); |
| 225 | assert.equal(bound[1], "command"); |
| 226 | assert.equal(bound[2], "margo"); |
| 227 | const body = String(bound[3]); |
| 228 | assert.ok(body.startsWith("npm test\n"), "the first line is the command"); |
| 229 | assert.match(body, /\[cut: 1000 more characters\]$/); |
| 230 | assert.equal(bound[4], "/home/agent/sessions/asn_1"); |
| 231 | assert.equal(bound[5], "exit 1 · 43 s · output cut"); |
| 232 | }); |