Skip to content
232 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { AbilitySection, AgentComputerCommand, User } from "@g1t/contracts";
5
6import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts";
7import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
8import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts";
9import { hasComputer, sessionCwd } from "./computer.ts";
10import { DEFAULT_AUTONOMY } from "./definition.ts";
11import { type AbilityPorts, type ActionPorts, type ComputerPorts, type ToolPorts, ToolBox } from "./tools.ts";
12import { commandEvent, commandOutcome } from "./transcript.ts";
13
14// ── A small world ────────────────────────────────────────────────────────
15
16const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" };
17const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User;
18
19function 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
29const 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
42const actions: ActionPorts = {
43 remember: async () => ({ ok: true, message: "" }),
44 forget: async () => ({ ok: true, message: "" }),
45 draftIssue: async () => ({ ok: true, message: "" }),
46};
47
48function 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. */
72function 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
105async 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
118const names = (tools: ToolBox) => tools.definitions().map((t) => t.name);
119
120// ── Offering ─────────────────────────────────────────────────────────────
121
122test("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
130test("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
148test("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
165test("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
177test("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
186test("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
198test("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
207test("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});