@akanjs/devkit 3.0.0-beta.14 → 3.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -0
  2. package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
  3. package/codeAgent/agent/AkanCodeServices.ts +104 -0
  4. package/codeAgent/agent/CodeAgent.ts +543 -0
  5. package/codeAgent/agent/CodeAgentAsks.ts +87 -0
  6. package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
  7. package/codeAgent/agent/CodeAgentGate.ts +119 -0
  8. package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
  9. package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
  10. package/codeAgent/agent/CodeAgentUi.ts +52 -0
  11. package/codeAgent/agent/CodeMailbox.ts +175 -0
  12. package/codeAgent/agent/CodeSessionFork.ts +71 -0
  13. package/codeAgent/agent/CodeSessionIndex.ts +104 -0
  14. package/codeAgent/agent/SubagentPool.ts +173 -0
  15. package/codeAgent/agent/akanCodeModel.ts +81 -0
  16. package/codeAgent/agent/akanCodePaths.ts +31 -0
  17. package/codeAgent/agent/akanSystemPrompt.ts +49 -0
  18. package/codeAgent/agent/codeAgentContract.test.ts +529 -0
  19. package/codeAgent/agent/codeMailbox.test.ts +80 -0
  20. package/codeAgent/agent/codeSessionFork.test.ts +60 -0
  21. package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
  22. package/codeAgent/agent/consoleToStderr.ts +18 -0
  23. package/codeAgent/feedback/DevLogFeedback.ts +97 -0
  24. package/codeAgent/feedback/PreviewChrome.ts +106 -0
  25. package/codeAgent/feedback/PreviewFeedback.ts +53 -0
  26. package/codeAgent/feedback/PreviewView.ts +107 -0
  27. package/codeAgent/feedback/TurnFeedback.ts +113 -0
  28. package/codeAgent/feedback/VerifyFeedback.ts +46 -0
  29. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  30. package/codeAgent/index.ts +43 -0
  31. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  32. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  33. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  34. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  35. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  36. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  37. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  38. package/codeAgent/skills/akan-store/SKILL.md +63 -0
  39. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  40. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  41. package/codeAgent/tools/AkanCli.ts +62 -0
  42. package/codeAgent/tools/AkanCodePlugins.ts +135 -0
  43. package/codeAgent/tools/AkanEditScope.ts +58 -0
  44. package/codeAgent/tools/AkanEnvKeys.ts +56 -0
  45. package/codeAgent/tools/AkanToolPack.ts +114 -0
  46. package/codeAgent/tools/AkanVerifier.ts +55 -0
  47. package/codeAgent/tools/MailToolPack.ts +85 -0
  48. package/codeAgent/tools/McpClient.ts +162 -0
  49. package/codeAgent/tools/McpToolPack.ts +134 -0
  50. package/codeAgent/tools/SessionToolPack.ts +101 -0
  51. package/codeAgent/tools/WebToolPack.ts +153 -0
  52. package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
  53. package/executors.ts +5 -0
  54. package/index.ts +2 -0
  55. package/package.json +8 -2
  56. package/piDependencyPins.test.ts +55 -0
  57. package/stripAnsi.ts +3 -0
  58. package/testHelpers.ts +179 -0
  59. package/types.ts +1 -0
@@ -0,0 +1,49 @@
1
+ import type { CodeAgentProfile } from "akanjs/common";
2
+
3
+ /**
4
+ * What the agent has to know that `AGENTS.md` does not say.
5
+ *
6
+ * The workspace guide is already loaded whole — 104KB, roughly 26k tokens, on every turn — so this stays short
7
+ * and carries only what is about *this* runtime: which tools exist, what the profile refuses, and the one rule
8
+ * the guide states for editors but not for an agent that can run commands itself.
9
+ */
10
+ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
11
+ const lines = [
12
+ "You are the akan coding agent, running inside an Akan.js workspace.",
13
+ "",
14
+ "- Prefer an akan workflow to a direct source edit. `list_workflows` / `explain_workflow` / `plan_workflow`, then `apply_workflow({ planPath })`. Editing source by hand is the fallback, not the default.",
15
+ "- Read context with `inspect_akan_context` before reading source bodies; it answers most structural questions without spending the file.",
16
+ "- Fetch the guideline for an area before a deep pass on it: `get_guideline`.",
17
+ "- Never hand-edit a generated file (`lib/cnst.ts`, `db.ts`, `st.ts`, any `*/index.ts` barrel). Fix the owning source and run `repair_generated` or `akan sync`.",
18
+ ];
19
+ if (profile.tools.builtin.includes("write"))
20
+ lines.push(
21
+ "- After editing, call `akan_verify`. It runs sync, lint, typecheck and the SSR check over what the working tree changed. A turn that edits and does not verify is not finished.",
22
+ );
23
+ else
24
+ lines.push(
25
+ "- This session is read-only: there is no write, edit or bash tool. Report what should change instead of changing it.",
26
+ );
27
+ if (profile.tools.subagent)
28
+ lines.push(
29
+ '- Hand a self-contained search or read to `task` — `type: "explore"` reads and reports, `type: "code"` makes the change. Only its answer comes back, so give it everything it needs and ask for exactly what you want returned. Do it yourself when the reading is short or when you need the files in front of you.',
30
+ );
31
+ if (profile.approval !== "never")
32
+ lines.push(
33
+ `- Some tool calls need the user's approval (policy: ${profile.approval}). A refusal is an answer; do not retry it a different way.`,
34
+ );
35
+ lines.push(
36
+ "- Secrets are refused at the tool boundary. If you need a value from `.env`, ask the user for it rather than looking for another way to read the file.",
37
+ "",
38
+ // The answer lands in a terminal pane, not a document: a preamble scrolls the thing being read off the
39
+ // screen, and a summary of tool calls repeats rows the host already drew above the answer.
40
+ "## How to answer",
41
+ "",
42
+ "- Answer in as few words as carry the answer. A short question gets one sentence with no heading.",
43
+ "- Do not open with what you are about to do, and do not close with a summary of what you did. The tool calls are already on the user's screen.",
44
+ "- Skip headings, bold labels and bullet lists unless the answer is genuinely a list. Prose is shorter.",
45
+ "- State a file as `path:line`. Do not paste a file back that the user can open.",
46
+ "- Say what you could not do, and why, in one line. Do not offer alternatives nobody asked for.",
47
+ );
48
+ return lines.join("\n");
49
+ };
@@ -0,0 +1,529 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ CodeAgentClient,
4
+ type CodeAgentEvent,
5
+ type CodeAgentQuestion,
6
+ type CodeAgentToolSummary,
7
+ type CodeAgentTransport,
8
+ codeAgentEventLabel,
9
+ codeAgentEventPersistence,
10
+ codeAgentPresets,
11
+ codeAgentShouldPersist,
12
+ isCodeAgentPresetName,
13
+ } from "akanjs/common";
14
+ import { akanCodeModelWarnings } from "./akanCodeModel";
15
+ import { CodeAgentAsks } from "./CodeAgentAsks";
16
+ import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
17
+ import { CodeAgentGate } from "./CodeAgentGate";
18
+ import { CodeAgentStreamPrinter } from "./CodeAgentStreamPrinter";
19
+
20
+ const root = "/tmp/akan-code-test";
21
+
22
+ class FakeTransport implements CodeAgentTransport {
23
+ sent: string[] = [];
24
+ #handler: (line: string) => void = () => {};
25
+ send(line: string) {
26
+ this.sent.push(line);
27
+ }
28
+ onLine(handler: (line: string) => void) {
29
+ this.#handler = handler;
30
+ }
31
+ deliver(frame: unknown) {
32
+ this.#handler(JSON.stringify(frame));
33
+ }
34
+ }
35
+
36
+ const event = (seq: number, body: Partial<CodeAgentEvent> = {}) => ({ type: "idle", seq, ...body }) as CodeAgentEvent;
37
+
38
+ describe("CodeAgentClient", () => {
39
+ test("correlates a reply with the command that asked for it", async () => {
40
+ const transport = new FakeTransport();
41
+ const client = new CodeAgentClient(transport);
42
+ const promise = client.send({ type: "get_state" });
43
+ const request = JSON.parse(transport.sent[0] ?? "{}") as { id: string };
44
+ transport.deliver({ type: "reply", id: request.id, ok: true, data: { sessionId: "s1" } });
45
+ expect(await promise).toEqual({ sessionId: "s1" });
46
+ });
47
+
48
+ test("rejects when the core reports the command failed", async () => {
49
+ const transport = new FakeTransport();
50
+ const client = new CodeAgentClient(transport);
51
+ const promise = client.send({ type: "abort" });
52
+ const request = JSON.parse(transport.sent[0] ?? "{}") as { id: string };
53
+ transport.deliver({ type: "reply", id: request.id, ok: false, error: "nothing running" });
54
+ await expect(promise).rejects.toThrow("nothing running");
55
+ });
56
+
57
+ test("drops a replayed frame and keeps the watermark monotonic", () => {
58
+ const transport = new FakeTransport();
59
+ const client = new CodeAgentClient(transport);
60
+ const seen: number[] = [];
61
+ client.on((e) => seen.push(e.seq));
62
+ for (const seq of [1, 2, 2, 1, 3]) transport.deliver({ type: "event", event: event(seq) });
63
+ expect(seen).toEqual([1, 2, 3]);
64
+ expect(client.lastSeq).toBe(3);
65
+ });
66
+
67
+ test("a new session resets the watermark, or its first frames read as duplicates", () => {
68
+ const transport = new FakeTransport();
69
+ const client = new CodeAgentClient(transport);
70
+ const seen: number[] = [];
71
+ client.on((e) => seen.push(e.seq));
72
+ transport.deliver({ type: "event", event: event(7) });
73
+ client.resetSeq();
74
+ transport.deliver({ type: "event", event: event(1) });
75
+ expect(seen).toEqual([7, 1]);
76
+ });
77
+
78
+ test("ignores a line that is not a frame instead of tearing the stream down", () => {
79
+ const transport = new FakeTransport();
80
+ const client = new CodeAgentClient(transport);
81
+ const seen: number[] = [];
82
+ client.on((e) => seen.push(e.seq));
83
+ transport.deliver("not json" as unknown);
84
+ client.push('{"type":"event","event":{"type":"idle","seq":1}}\n');
85
+ expect(seen).toEqual([1]);
86
+ });
87
+ });
88
+
89
+ describe("codeAgentPresets", () => {
90
+ test("review gives no write, bash or web tools", () => {
91
+ const profile = codeAgentPresets.review(root);
92
+ expect(profile.tools.builtin).toEqual(["read", "ls", "grep", "find"]);
93
+ expect(profile.tools.web).toEqual({ fetch: false, search: false });
94
+ expect(profile.context.projectFiles).toBe(false);
95
+ });
96
+
97
+ test("a pod cannot prompt and therefore suspends both interactions", () => {
98
+ const profile = codeAgentPresets.pod(root);
99
+ expect(profile.ui.canPrompt).toBe(false);
100
+ expect(profile.interaction).toEqual({ question: "suspend", approval: "suspend" });
101
+ });
102
+
103
+ test("web asks before writing even though the runner is isolated", () => {
104
+ expect(codeAgentPresets.web(root).approval).toBe("writes");
105
+ expect(codeAgentPresets.local(root).approval).toBe("never");
106
+ });
107
+
108
+ test("every preset denies env files and secrets", () => {
109
+ for (const name of ["local", "pod", "review", "web"] as const)
110
+ expect(codeAgentPresets[name](root).paths.deny).toContain("**/.env");
111
+ });
112
+
113
+ test("MCP discovery is on where a person is driving and off where nobody vetted the server", () => {
114
+ expect(codeAgentPresets.local(root).tools.mcp).toEqual([]);
115
+ expect(codeAgentPresets.web(root).tools.mcp).toEqual([]);
116
+ expect(codeAgentPresets.review(root).tools.mcp).toBe("off");
117
+ expect(codeAgentPresets.pod(root).tools.mcp).toBe("off");
118
+ });
119
+
120
+ test("isCodeAgentPresetName refuses an unknown name", () => {
121
+ expect(isCodeAgentPresetName("local")).toBe(true);
122
+ expect(isCodeAgentPresetName("prod")).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe("CodeAgentGate", () => {
127
+ test("blocks a path on the deny list", () => {
128
+ const gate = new CodeAgentGate(codeAgentPresets.local(root));
129
+ expect(gate.verdict("read", { path: ".env" }).block).toContain("deny list");
130
+ expect(gate.verdict("read", { path: "apps/a/secrets/token.json" }).block).toContain("deny list");
131
+ });
132
+
133
+ test("blocks a path that escapes the agent root", () => {
134
+ const gate = new CodeAgentGate(codeAgentPresets.local(root));
135
+ expect(gate.verdict("read", { path: "../../etc/passwd" }).block).toContain("outside the agent root");
136
+ expect(gate.verdict("read", { path: "/etc/passwd" }).block).toContain("outside the agent root");
137
+ });
138
+
139
+ test("lets an ordinary path through", () => {
140
+ const gate = new CodeAgentGate(codeAgentPresets.local(root));
141
+ expect(gate.verdict("read", { path: "apps/a/lib/post/post.service.ts" })).toEqual({});
142
+ });
143
+
144
+ test("approval:writes asks for write and edit only", () => {
145
+ const gate = new CodeAgentGate(codeAgentPresets.web(root));
146
+ expect(gate.verdict("write", { path: "a.ts" }).approval).toBe("write a.ts");
147
+ expect(gate.verdict("edit", { path: "a.ts" }).approval).toBe("edit a.ts");
148
+ expect(gate.verdict("read", { path: "a.ts" }).approval).toBeUndefined();
149
+ expect(gate.verdict("bash", { command: "ls" }).approval).toBeUndefined();
150
+ });
151
+
152
+ test("approval:commands summarises the command it is asking about", () => {
153
+ const profile = { ...codeAgentPresets.local(root), approval: "commands" as const };
154
+ const gate = new CodeAgentGate(profile);
155
+ expect(gate.verdict("bash", { command: "rm -rf build" }).approval).toBe("run: rm -rf build");
156
+ });
157
+
158
+ test("an allow list narrows further than the root", () => {
159
+ const profile = codeAgentPresets.local(root);
160
+ const gate = new CodeAgentGate({ ...profile, paths: { ...profile.paths, allow: ["apps/**"] } });
161
+ expect(gate.verdict("read", { path: "apps/a/x.ts" }).block).toBeUndefined();
162
+ expect(gate.verdict("read", { path: "libs/b/x.ts" }).block).toContain("allow list");
163
+ });
164
+ });
165
+
166
+ describe("CodeAgentAsks", () => {
167
+ const questionOf = (questionId: string): CodeAgentQuestion => ({
168
+ questionId,
169
+ prompt: "which colour?",
170
+ kind: "select",
171
+ options: [
172
+ { key: "teal", label: "Teal" },
173
+ { key: "rose", label: "Rose" },
174
+ ],
175
+ });
176
+
177
+ test("answers the pending question and ignores a stale id", async () => {
178
+ const asks = new CodeAgentAsks();
179
+ const id = asks.nextId("q");
180
+ const promise = asks.openQuestion(questionOf(id));
181
+ expect(asks.answer("q99", "wrong")).toBe(false);
182
+ expect(asks.answer(id, "teal")).toBe(true);
183
+ expect(await promise).toBe("teal");
184
+ expect(asks.answer(id, "again")).toBe(false);
185
+ });
186
+
187
+ test("approvals are a queue, so two in a row both resolve", async () => {
188
+ const asks = new CodeAgentAsks();
189
+ const first = asks.nextId("a");
190
+ const second = asks.nextId("a");
191
+ const promises = [asks.openApproval(first), asks.openApproval(second)];
192
+ asks.resolveApproval(second, false);
193
+ asks.resolveApproval(first, true);
194
+ expect(await Promise.all(promises)).toEqual([true, false]);
195
+ });
196
+
197
+ test("clearing resolves everything so no caller is stranded", async () => {
198
+ const asks = new CodeAgentAsks();
199
+ const question = asks.openQuestion(questionOf(asks.nextId("q")));
200
+ const approval = asks.openApproval(asks.nextId("a"));
201
+ asks.clear();
202
+ expect(await question).toBe("");
203
+ expect(await approval).toBe(false);
204
+ expect(asks.pendingQuestionId).toBeUndefined();
205
+ expect(asks.hasPendingApproval).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("CodeAgentEventMapper", () => {
210
+ const start = { type: "agent_start" } as never;
211
+
212
+ test("a run becomes one turn, not one turn per round trip", () => {
213
+ const mapper = new CodeAgentEventMapper();
214
+ expect(mapper.map(start)).toEqual([{ type: "turn_start", turnId: "t1" }]);
215
+ expect(mapper.map({ type: "turn_start" } as never)).toEqual([]);
216
+ expect(mapper.map(start)).toEqual([{ type: "turn_start", turnId: "t2" }]);
217
+ });
218
+
219
+ test("streams text and thinking deltas apart", () => {
220
+ const mapper = new CodeAgentEventMapper();
221
+ mapper.map(start);
222
+ const text = mapper.map({
223
+ type: "message_update",
224
+ assistantMessageEvent: { type: "text_delta", delta: "hi" },
225
+ } as never);
226
+ const think = mapper.map({
227
+ type: "message_update",
228
+ assistantMessageEvent: { type: "thinking_delta", delta: "hmm" },
229
+ } as never);
230
+ expect(text).toEqual([{ type: "text_delta", turnId: "t1", text: "hi" }]);
231
+ expect(think).toEqual([{ type: "thinking_delta", turnId: "t1", text: "hmm" }]);
232
+ });
233
+
234
+ /**
235
+ * `String({})` is `"[object Object]"`, and an `edit` call's argument is an array of them — so a row that
236
+ * should read "three replacements" read as two identical placeholders instead.
237
+ */
238
+ test("a structured argument is described by its shape, not stringified", () => {
239
+ const mapper = new CodeAgentEventMapper();
240
+ mapper.map(start);
241
+ const [event] = mapper.map({
242
+ type: "tool_execution_start",
243
+ toolCallId: "c1",
244
+ toolName: "edit",
245
+ args: { path: "/repo/a.ts", edits: [{ old: "x" }, { old: "y" }], options: { dryRun: false } },
246
+ } as never);
247
+ const title = event?.type === "tool_start" ? event.tool.title : "";
248
+ expect(title).not.toContain("[object Object]");
249
+ expect(title).toContain("edits=2 items");
250
+ expect(title).toContain("options={1 key}");
251
+ // A path still reads as itself: it is the one argument worth showing verbatim.
252
+ expect(title).toContain("path=/repo/a.ts");
253
+ });
254
+
255
+ test("a blocked call gets a start frame and the engine's end frame is relabelled once", () => {
256
+ const mapper = new CodeAgentEventMapper();
257
+ mapper.map(start);
258
+ const tool: CodeAgentToolSummary = {
259
+ toolCallId: "c1",
260
+ name: "read",
261
+ op: "read",
262
+ path: ".env",
263
+ title: "read(path=.env)",
264
+ };
265
+ expect(mapper.markBlocked("c1", "read", { path: ".env" }, "denied")).toEqual([
266
+ { type: "tool_start", turnId: "t1", tool },
267
+ ]);
268
+ const ended = mapper.map({
269
+ type: "tool_execution_end",
270
+ toolCallId: "c1",
271
+ toolName: "read",
272
+ result: {},
273
+ isError: true,
274
+ } as never);
275
+ expect(ended).toEqual([
276
+ { type: "tool_end", turnId: "t1", tool, outcome: "blocked", output: "denied", truncated: false },
277
+ ]);
278
+ expect(mapper.flushBlocked()).toEqual([]);
279
+ });
280
+
281
+ test("a refusal the engine never closes is swept at the turn boundary", () => {
282
+ const mapper = new CodeAgentEventMapper();
283
+ mapper.map(start);
284
+ mapper.markBlocked("c2", "bash", { command: "rm" }, "denied");
285
+ const frames = mapper.map({ type: "agent_end", messages: [], willRetry: false } as never);
286
+ expect(frames.map((f) => f.type)).toEqual(["tool_end", "turn_end"]);
287
+ });
288
+
289
+ test("reports an aborted run as aborted rather than done", () => {
290
+ const mapper = new CodeAgentEventMapper();
291
+ mapper.map(start);
292
+ const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "aborted" }], willRetry: false } as never);
293
+ expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
294
+ });
295
+ });
296
+
297
+ describe("CodeAgentStreamPrinter", () => {
298
+ const collect = (events: CodeAgentEvent[], json = false) => {
299
+ const lines: string[] = [];
300
+ const printer = new CodeAgentStreamPrinter({ json, write: (text) => lines.push(text) });
301
+ for (const e of events) printer.print(e);
302
+ printer.finish();
303
+ return lines.join("");
304
+ };
305
+
306
+ test("closes a streamed line before printing a tool row", () => {
307
+ const output = collect([
308
+ { type: "text_delta", turnId: "t1", text: "working", seq: 1 },
309
+ { type: "tool_start", turnId: "t1", tool: { toolCallId: "c1", name: "ls", op: "list", title: "ls" }, seq: 2 },
310
+ ]);
311
+ expect(output.split("\n")[0]).toBe("working");
312
+ expect(output).toContain("ls");
313
+ });
314
+
315
+ test("json mode prints the event verbatim, one per line", () => {
316
+ const output = collect([{ type: "idle", seq: 4 }], true);
317
+ expect(JSON.parse(output)).toEqual({ type: "idle", seq: 4 });
318
+ });
319
+
320
+ test("hides thinking unless it was asked for", () => {
321
+ const lines: string[] = [];
322
+ const printer = new CodeAgentStreamPrinter({ write: (t) => lines.push(t) });
323
+ printer.print({ type: "thinking_delta", turnId: "t1", text: "secret", seq: 1 });
324
+ expect(lines.join("")).toBe("");
325
+ });
326
+ });
327
+
328
+ describe("codeAgentEventLabel", () => {
329
+ test("renders every event kind without throwing", () => {
330
+ const events: CodeAgentEvent[] = [
331
+ {
332
+ type: "session",
333
+ info: {
334
+ sessionId: "s",
335
+ cwd: root,
336
+ profile: "local",
337
+ model: undefined,
338
+ tools: [],
339
+ contextTokens: undefined,
340
+ effort: undefined,
341
+ name: undefined,
342
+ interaction: { question: "await", approval: "await" },
343
+ },
344
+ seq: 1,
345
+ },
346
+ { type: "turn_start", turnId: "t1", seq: 2 },
347
+ { type: "turn_end", turnId: "t1", stopReason: "done", seq: 3 },
348
+ { type: "message", turnId: "t1", role: "assistant", text: "hi", seq: 4 },
349
+ {
350
+ type: "tool_end",
351
+ turnId: "t1",
352
+ tool: { toolCallId: "c", name: "ls", op: "list", title: "ls" },
353
+ outcome: "blocked",
354
+ output: "no",
355
+ truncated: false,
356
+ seq: 5,
357
+ },
358
+ { type: "question", question: { questionId: "q1", prompt: "which?", kind: "text" }, seq: 6 },
359
+ { type: "host", kind: "preview", id: "p1", payload: { url: "http://localhost:8282" }, seq: 14 },
360
+ {
361
+ type: "approval",
362
+ request: {
363
+ approvalId: "a1",
364
+ toolCallId: "c",
365
+ name: "write",
366
+ summary: "write a.ts",
367
+ policy: "writes",
368
+ },
369
+ seq: 7,
370
+ },
371
+ { type: "compaction", phase: "start", reason: "threshold", seq: 8 },
372
+ { type: "retry", attempt: 1, maxAttempts: 3, delayMs: 100, message: "overloaded", seq: 9 },
373
+ { type: "queue", steering: [], followUp: ["x"], seq: 10 },
374
+ { type: "notice", level: "warning", message: "careful", seq: 11 },
375
+ { type: "error", message: "boom", fatal: true, seq: 12 },
376
+ { type: "idle", seq: 13 },
377
+ ];
378
+ for (const e of events) expect(codeAgentEventLabel(e).length).toBeGreaterThan(0);
379
+ });
380
+ });
381
+
382
+ describe("CodeAgentGate bash", () => {
383
+ test("refuses a shell command that names a denied file", () => {
384
+ const gate = new CodeAgentGate(codeAgentPresets.local(root));
385
+ expect(gate.verdict("bash", { command: "cat .env" }).block).toContain(".env");
386
+ expect(gate.verdict("bash", { command: "cat ../../.env" }).block).toContain(".env");
387
+ expect(gate.verdict("bash", { command: "ls secrets/" }).block).toContain("secrets/");
388
+ expect(gate.verdict("bash", { command: "openssl x509 -in server.pem" }).block).toContain(".pem");
389
+ });
390
+
391
+ test("leaves an ordinary command alone", () => {
392
+ const gate = new CodeAgentGate(codeAgentPresets.local(root));
393
+ expect(gate.verdict("bash", { command: "bun test --isolate" })).toEqual({});
394
+ expect(gate.verdict("bash", { command: "grep -r TODO ." })).toEqual({});
395
+ });
396
+ });
397
+
398
+ describe("codeAgentShouldPersist", () => {
399
+ test("follows the table for engine frames", () => {
400
+ expect(codeAgentShouldPersist({ type: "message", turnId: "t1", role: "assistant", text: "hi" })).toBe(true);
401
+ expect(codeAgentShouldPersist({ type: "text_delta", turnId: "t1", text: "hi" })).toBe(false);
402
+ expect(codeAgentShouldPersist({ type: "thinking_delta", turnId: "t1", text: "hm" })).toBe(false);
403
+ expect(codeAgentShouldPersist({ type: "error", message: "boom", fatal: false })).toBe(true);
404
+ });
405
+
406
+ test("a host frame decides per frame, not per kind", () => {
407
+ const running = { type: "host" as const, kind: "step", id: "s1", payload: { status: "pending" } };
408
+ const done = { type: "host" as const, kind: "step", id: "s1", persist: true, payload: { status: "done" } };
409
+ expect(codeAgentShouldPersist(running)).toBe(false);
410
+ expect(codeAgentShouldPersist(done)).toBe(true);
411
+ });
412
+
413
+ test("the table answers every event type", () => {
414
+ const types = Object.keys(codeAgentEventPersistence);
415
+ expect(types).toContain("host");
416
+ expect(types.every((type) => ["live", "persist"].includes(codeAgentEventPersistence[type as never]))).toBe(true);
417
+ });
418
+ });
419
+
420
+ describe("turn outcome", () => {
421
+ const start = { type: "agent_start" } as never;
422
+ const finished = (stopReason: string) =>
423
+ ({ type: "agent_end", messages: [{ stopReason }], willRetry: false }) as never;
424
+
425
+ test("an abort is reported even though the engine calls it a normal stop", () => {
426
+ const mapper = new CodeAgentEventMapper();
427
+ mapper.map(start);
428
+ mapper.noteOutcome("aborted");
429
+ expect(mapper.map(finished("stop"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
430
+ });
431
+
432
+ test("suspending to ask a person ends the turn as awaiting", () => {
433
+ const mapper = new CodeAgentEventMapper();
434
+ mapper.map(start);
435
+ mapper.noteOutcome("awaiting");
436
+ expect(mapper.map(finished("stop"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "awaiting" }]);
437
+ });
438
+
439
+ test("the note is consumed once and never colours the next turn", () => {
440
+ const mapper = new CodeAgentEventMapper();
441
+ mapper.map(start);
442
+ mapper.noteOutcome("aborted");
443
+ mapper.map(finished("stop"));
444
+ mapper.map(start);
445
+ expect(mapper.map(finished("stop"))).toEqual([{ type: "turn_end", turnId: "t2", stopReason: "done" }]);
446
+ });
447
+
448
+ test("a note left by an abort that hit an idle session is dropped at the next turn", () => {
449
+ const mapper = new CodeAgentEventMapper();
450
+ mapper.noteOutcome("aborted");
451
+ mapper.map(start);
452
+ expect(mapper.map(finished("stop"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "done" }]);
453
+ });
454
+
455
+ test("falls back to the engine when the core noted nothing", () => {
456
+ const mapper = new CodeAgentEventMapper();
457
+ mapper.map(start);
458
+ expect(mapper.map(finished("error"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "error" }]);
459
+ });
460
+ });
461
+
462
+ describe("truncation", () => {
463
+ const start = { type: "agent_start" } as never;
464
+
465
+ test("the engine's `length` is a partial success, not a completed turn", () => {
466
+ const mapper = new CodeAgentEventMapper();
467
+ mapper.map(start);
468
+ const frames = mapper.map({
469
+ type: "agent_end",
470
+ messages: [{ stopReason: "length" }],
471
+ willRetry: false,
472
+ } as never);
473
+ expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "truncated" }]);
474
+ });
475
+
476
+ test("an abort still wins over the engine's report", () => {
477
+ const mapper = new CodeAgentEventMapper();
478
+ mapper.map(start);
479
+ mapper.noteOutcome("aborted");
480
+ const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "length" }], willRetry: false } as never);
481
+ expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
482
+ });
483
+
484
+ test("the terminal says so, because a cut answer looks like a finished one", () => {
485
+ const lines: string[] = [];
486
+ const printer = new CodeAgentStreamPrinter({ write: (text) => lines.push(text) });
487
+ printer.print({ type: "turn_end", turnId: "t1", stopReason: "done", seq: 1 });
488
+ expect(lines.join("")).toBe("");
489
+ printer.print({ type: "turn_end", turnId: "t1", stopReason: "truncated", seq: 2 });
490
+ expect(lines.join("")).toContain("cut off");
491
+ });
492
+ });
493
+
494
+ describe("akanCodeModelWarnings", () => {
495
+ const model = (contextWindow: number) =>
496
+ ({ provider: "custom", id: "m", contextWindow }) as unknown as Parameters<typeof akanCodeModelWarnings>[0];
497
+
498
+ test("says so when the window cannot hold the compaction policy", () => {
499
+ expect(akanCodeModelWarnings(model(8_192), 36_384)[0]).toContain("compact immediately");
500
+ });
501
+
502
+ test("stays quiet for an ordinary small-output model", () => {
503
+ expect(akanCodeModelWarnings(model(128_000), 36_384)).toEqual([]);
504
+ });
505
+
506
+ test("no model is not a warning", () => {
507
+ expect(akanCodeModelWarnings(undefined, 36_384)).toEqual([]);
508
+ });
509
+ });
510
+
511
+ describe("wire coverage", () => {
512
+ /**
513
+ * The audit that found `context` declared, rendered, and never emitted. A contract entry with no producer
514
+ * costs every host a branch for a frame that cannot arrive.
515
+ */
516
+ test("every event type either has a producer in the core or is documented as host-supplied", async () => {
517
+ const dir = import.meta.dir;
518
+ const sources = await Promise.all(
519
+ ["CodeAgent.ts", "CodeAgentEventMapper.ts"].map(async (file) => await Bun.file(`${dir}/${file}`).text()),
520
+ );
521
+ const core = sources.join("\n");
522
+ // `host` is the one frame the core never makes: it is the slot a host fills with its own vocabulary.
523
+ const hostSupplied = new Set(["host"]);
524
+ const missing = Object.keys(codeAgentEventPersistence).filter(
525
+ (type) => !hostSupplied.has(type) && !core.includes(`type: "${type}"`),
526
+ );
527
+ expect(missing).toEqual([]);
528
+ });
529
+ });
@@ -0,0 +1,80 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { CodeMailbox } from "./CodeMailbox";
6
+
7
+ const boxes: CodeMailbox[] = [];
8
+ const open = (dir: string, id: string, name: string, onMail: (mail: { text: string }) => void = () => undefined) => {
9
+ const box = new CodeMailbox(dir, id, name);
10
+ box.open("/repo", onMail);
11
+ boxes.push(box);
12
+ return box;
13
+ };
14
+
15
+ afterEach(() => {
16
+ for (const box of boxes.splice(0)) box.close();
17
+ });
18
+
19
+ describe("CodeMailbox", () => {
20
+ test("each session sees the others and not itself", () => {
21
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
22
+ const one = open(dir, "aaaa1111", "gateway-work");
23
+ const two = open(dir, "bbbb2222", "store-work");
24
+ expect(one.peers().map((peer) => peer.name)).toEqual(["store-work"]);
25
+ expect(two.peers().map((peer) => peer.name)).toEqual(["gateway-work"]);
26
+ });
27
+
28
+ test("a message lands in the other session's inbox and is delivered once", async () => {
29
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
30
+ const got: string[] = [];
31
+ const one = open(dir, "aaaa1111", "gateway-work");
32
+ open(dir, "bbbb2222", "store-work", (mail) => got.push(mail.text));
33
+ one.send("bbbb2222", "the adapter moved to srvkit");
34
+ await Bun.sleep(CodeMailbox.pollMs + 400);
35
+ expect(got).toEqual(["the adapter moved to srvkit"]);
36
+ // The poll runs again; a message already handed over must not be handed over twice.
37
+ await Bun.sleep(CodeMailbox.pollMs + 200);
38
+ expect(got).toEqual(["the adapter moved to srvkit"]);
39
+ });
40
+
41
+ /** What was already in the inbox belongs to an earlier run, which read it or never will. */
42
+ test("a session opening on a full inbox does not replay it", async () => {
43
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
44
+ // Opened once to create the directories, then closed, then seeded the way a previous run would have.
45
+ open(dir, "cccc3333", "seeded").close();
46
+ const inbox = path.join(dir, "inbox", "cccc3333.jsonl");
47
+ writeFileSync(inbox, `${JSON.stringify({ from: "x", fromName: "x", text: "old" })}\n`);
48
+ const got: string[] = [];
49
+ open(dir, "cccc3333", "seeded", (mail) => got.push(mail.text));
50
+ await Bun.sleep(CodeMailbox.pollMs + 400);
51
+ expect(got).toEqual([]);
52
+ });
53
+
54
+ test("a session that stopped beating is not offered as somewhere to send", () => {
55
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
56
+ const one = open(dir, "aaaa1111", "alive");
57
+ const stale = { id: "dddd4444", name: "crashed", cwd: "/repo", pid: 1, at: Date.now() - CodeMailbox.staleMs * 2 };
58
+ writeFileSync(path.join(dir, "live", "dddd4444.json"), JSON.stringify(stale));
59
+ expect(one.peers()).toEqual([]);
60
+ expect(one.find("crashed")).toBeUndefined();
61
+ });
62
+
63
+ test("a name or the front of an id finds one peer, and an ambiguous one finds none", () => {
64
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
65
+ const one = open(dir, "aaaa1111", "alive");
66
+ open(dir, "bbbb2222", "store-work");
67
+ open(dir, "bbbb3333", "store-other");
68
+ expect(one.find("store-work")?.id).toBe("bbbb2222");
69
+ expect(one.find("bbbb2")?.id).toBe("bbbb2222");
70
+ // Two names starting with `store` — sending to the wrong session is worse than not sending.
71
+ expect(one.find("store")).toBeUndefined();
72
+ });
73
+
74
+ test("a runaway exchange is stopped by the ceiling rather than by the disk", () => {
75
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
76
+ const one = open(dir, "aaaa1111", "alive");
77
+ for (let at = 0; at < CodeMailbox.sendsPerMinute; at += 1) one.send("bbbb2222", "ping");
78
+ expect(() => one.send("bbbb2222", "ping")).toThrow("a minute is the ceiling");
79
+ });
80
+ });