gentle-pi 2.4.0 → 2.6.0

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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import { TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
5
+ import { AgentsView } from "../lib/agents-view.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ const plainTheme = { fg: (_color: string, text: string) => text };
9
+
10
+ function task(id: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
11
+ return { id, agent: id, mode: "task", prompt: "p", label: "p", cwd: "/r", parentSessionId: "session", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "model", thinking: undefined, sessionPath: "/sessions/task.jsonl", error: null, result: null, lastStep: "working", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 0, cost: 0, ...overrides };
12
+ }
13
+
14
+ function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "click"): TuiMouseEvent {
15
+ return { type, button: type === "move" || type === "wheel" ? "none" : "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
16
+ }
17
+
18
+ test("AgentsView integrates flat current-session selection, semantic scrolling, live geometry, and idempotent close", () => {
19
+ const store = new TaskStore();
20
+ let rows = 8;
21
+ const closes: string[] = [];
22
+ const view = new AgentsView({
23
+ theme: plainTheme,
24
+ rows: () => rows,
25
+ store,
26
+ sessionId: "session",
27
+ now: () => 61_000,
28
+ onCancel() {},
29
+ onOpen() {},
30
+ onClose: () => closes.push("close"),
31
+ requestRender() {},
32
+ });
33
+ store.add(task("first"));
34
+ store.add(task("second"));
35
+ for (let index = 0; index < 12; index += 1) store.apply("first", { type: TASK_EVENT.TEXT, text: `semantic line ${index}\n` }, 2000);
36
+
37
+ const wide = 80;
38
+ let frame = view.render(wide);
39
+ const header = stripAnsi(frame[0] ?? "");
40
+ assert.equal(visibleWidth(header), wide);
41
+ assert.match(header, /\[× Close\]/, "a pane-capable header exposes the wide close control");
42
+ const closeX = visibleWidth(header.slice(0, header.indexOf("[× Close]")));
43
+
44
+ view.handleInput("k");
45
+ view.handleInput("\x1b[D");
46
+ assert.equal(view.selectedTask()?.id, "first", "up and left keep the first task selected without a group heading");
47
+ rows = 6;
48
+ assert.equal(view.handleMouse(mouse(closeX, 0, wide, frame.length)), undefined, "old close bounds are inert before the live resize frame");
49
+ frame = view.render(wide);
50
+ assert.equal(frame.length, rows, "the next frame uses the live height budget");
51
+ assert.doesNotMatch(frame.map(stripAnsi).join("\n"), /Current orchestrator|Select a task to inspect its thread/, "current-session tasks stay flat with an active detail pane");
52
+ assert.match(frame.map(stripAnsi).join("\n"), /semantic line 11/, "the selected task still follows its thread after resizing");
53
+
54
+ view.handleInput("\x1b[C");
55
+ assert.equal(view.selectedTask()?.id, "first", "right does not expand a heading in the flat list");
56
+ view.handleInput("j");
57
+ assert.equal(view.selectedTask()?.id, "second", "down selects the next task directly");
58
+ view.handleInput("k");
59
+ assert.equal(view.selectedTask()?.id, "first", "up returns directly to the first task");
60
+ view.render(wide);
61
+ view.handleInput("\x1b[5~");
62
+ frame = view.render(wide);
63
+ assert.doesNotMatch(frame.map(stripAnsi).join("\n"), /semantic line 11/, "manual thread scrolling remains off the tail after resizing");
64
+ const narrow = view.render(59).map(stripAnsi);
65
+ assert.equal(narrow.length, rows, "width 59 keeps a narrow single viewport");
66
+ assert.match(narrow[0] ?? "", /\[×\]/, "narrow mode keeps only the compact close control");
67
+ view.handleInput("\t");
68
+ const details = view.render(59).map(stripAnsi);
69
+ assert.match(details[0] ?? "", /\[← Back\]/, "Tab opens selected-task details directly from the flat list");
70
+ assert.match(details.join("\n"), /first · running/, "details belong to the selected task");
71
+ view.handleInput("\t");
72
+ assert.equal(view.selectedTask()?.id, "first", "returning from details preserves task selection");
73
+ rows = 2;
74
+ const fallback = view.render(59).map(stripAnsi);
75
+ assert.equal(fallback.length, 1, "a two-row terminal uses the one-line fallback");
76
+ assert.match(fallback[0] ?? "", /^× Close/, "fallback mode keeps a single Close cell");
77
+ assert.equal(view.handleMouse(mouse(1, 0, 59, fallback.length)), undefined, "fallback routing outside the Close cell is inert");
78
+
79
+ rows = 6;
80
+ frame = view.render(wide);
81
+ const reopenedHeader = stripAnsi(frame[0] ?? "");
82
+ const reopenedCloseX = visibleWidth(reopenedHeader.slice(0, reopenedHeader.indexOf("[× Close]")));
83
+ let notifications = 0;
84
+ const unsubscribe = store.subscribe("first", () => {
85
+ notifications += 1;
86
+ if (notifications > 1) throw new Error("the selected listener was rebound during a live update");
87
+ });
88
+ store.apply("first", { type: TASK_EVENT.TEXT, text: "after reopen" }, 2000);
89
+ assert.equal(notifications, 1, "reopening panes retains an idempotent selected-task subscription");
90
+ assert.equal(view.handleMouse(mouse(reopenedCloseX, 0, wide, frame.length)), undefined, "a task update invalidates the old close bounds");
91
+ frame = view.render(wide);
92
+ const currentHeader = stripAnsi(frame[0] ?? "");
93
+ const currentCloseX = visibleWidth(currentHeader.slice(0, currentHeader.indexOf("[× Close]")));
94
+ unsubscribe();
95
+ assert.equal(view.handleMouse(mouse(currentCloseX, 0, wide, frame.length))?.handled, true);
96
+ view.handleInput("\x1b");
97
+ view.handleInput("q");
98
+ assert.deepEqual(closes, ["close"], "pointer, Escape, and q share the same idempotent close action");
99
+ view.dispose();
100
+ });
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { CHILD_MESSAGE_MAX_BYTES, ChildMessenger, parseChildFrame } from "../lib/agents-messaging.ts";
7
+
8
+ test("notification and query frames admit only bounded exact schemas", () => {
9
+ assert.deepEqual(parseChildFrame({ id: "n1", kind: "notification", message: "💡\nready" }).frame, { id: "n1", kind: "notification", message: "💡\nready" });
10
+ assert.deepEqual(parseChildFrame({ id: "q1", kind: "query", message: "💡\nready" }).frame, { id: "q1", kind: "query", message: "💡\nready" });
11
+ for (const frame of [
12
+ { id: "n1", kind: "notification", message: "ready", sender: "forged" },
13
+ { id: "n1", kind: "query", message: "wrong grammar" },
14
+ { id: "q1", kind: "notification", message: "wrong grammar" },
15
+ { id: "q0", kind: "query", message: "leading zero" },
16
+ { id: `q${"1".repeat(1_000)}`, kind: "query", message: "unbounded correlation" },
17
+ { id: "q9007199254740992", kind: "query", message: "unsafe correlation" },
18
+ { id: "q1", kind: "query", message: "\uD800" },
19
+ { id: "q1", kind: "query", message: "💡".repeat(Math.floor(CHILD_MESSAGE_MAX_BYTES / 4) + 1) },
20
+ { id: 3, kind: "notification", message: "ready" },
21
+ ]) assert.ok(parseChildFrame(frame).error, `reject ${JSON.stringify(frame).slice(0, 80)}`);
22
+ });
23
+
24
+ test("child notification admissions require an exact acknowledgement and clean up", async () => {
25
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
26
+ const frames: Array<Record<string, unknown>> = [];
27
+ const timers: Array<() => void> = [];
28
+ const endpoint = { send: (frame: Record<string, unknown>) => { frames.push(frame); return true; }, on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]) };
29
+ const messenger = new ChildMessenger(endpoint, (fn) => { timers.push(fn); return () => {}; });
30
+ const pending = messenger.notify("wait");
31
+ let settled = false;
32
+ void pending.then(() => { settled = true; });
33
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: true, sender: "forged" });
34
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: false, error: "x".repeat(257) });
35
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: true, error: "x".repeat(8 * 1024 + 1) });
36
+ await Promise.resolve();
37
+ assert.equal(settled, false, "malformed acknowledgements cannot settle a pending notification");
38
+ assert.deepEqual(frames, [{ id: "n1", kind: "notification", message: "wait" }]);
39
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: true });
40
+ await pending;
41
+ const timedOut = messenger.notify("timeout");
42
+ timers.at(-1)!();
43
+ await assert.rejects(timedOut, /timed out/);
44
+ const inflight = Array.from({ length: 8 }, (_, index) => messenger.notify(`pending ${index}`));
45
+ await assert.rejects(messenger.notify("over capacity"), /too many/);
46
+ for (const listener of listeners.get("disconnect") ?? []) listener({});
47
+ assert.ok((await Promise.allSettled(inflight)).every((result) => result.status === "rejected"));
48
+ const afterDisconnect = messenger.notify("after disconnect");
49
+ void afterDisconnect.catch(() => {});
50
+ await Promise.resolve();
51
+ assert.equal(frames.length, 10, "a disconnected messenger cannot send another notification");
52
+ });
53
+
54
+ test("callback failures clear pending work but a closed messenger cannot retry", async () => {
55
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
56
+ const callbacks: Array<(error: Error | null) => void> = [];
57
+ const timers: Array<{ cancelled: boolean }> = [];
58
+ const endpoint = { send: (_frame: Record<string, unknown>, callback?: (error: Error | null) => void) => { callbacks.push(callback!); return true; }, on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]) };
59
+ const messenger = new ChildMessenger(endpoint, () => {
60
+ const timer = { cancelled: false };
61
+ timers.push(timer);
62
+ return () => { timer.cancelled = true; };
63
+ });
64
+ const failed = messenger.notify("first");
65
+ callbacks[0](new Error("send failed"));
66
+ await assert.rejects(failed, /send failed/);
67
+ const retry = messenger.notify("retry");
68
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n2", kind: "ack", accepted: true });
69
+ await retry;
70
+ messenger.close();
71
+ assert.ok(timers.every((timer) => timer.cancelled), "settled and closed work cancels every timer");
72
+ const afterClose = messenger.notify("after close");
73
+ void afterClose.catch(() => {});
74
+ await Promise.resolve();
75
+ assert.equal(callbacks.length, 2, "a closed messenger cannot retry through its endpoint");
76
+ const exhausted = new ChildMessenger(endpoint, () => () => {});
77
+ (exhausted as unknown as { nextId: number }).nextId = Number.MAX_SAFE_INTEGER;
78
+ await assert.rejects(exhausted.notify("overflow"), /correlation exhausted/);
79
+ });
80
+
81
+ test("a real Node IPC child exchanges a notification acknowledgement without stdout framing", async () => {
82
+ const child = spawn(process.execPath, [join(import.meta.dirname, "fixtures", "agents-messaging-child.mjs")], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
83
+ const messages: unknown[] = [];
84
+ let output = "";
85
+ const closed = once(child, "close");
86
+ child.on("message", (message) => messages.push(message));
87
+ child.stdout.on("data", (chunk) => { output += chunk; });
88
+ await once(child, "message");
89
+ assert.deepEqual(messages, [{ id: "n1", kind: "notification", message: "fixture ready" }]);
90
+ child.send({ id: "n1", kind: "ack", accepted: true });
91
+ const [code] = await closed;
92
+ assert.equal(code, 0);
93
+ assert.equal(output, '{"accepted":true}\n');
94
+ });
@@ -0,0 +1,198 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ applyTaskEvent,
5
+ emptyThread,
6
+ normalizeRpcEvent,
7
+ TASK_EVENT,
8
+ TASK_STATUS,
9
+ taskLabel,
10
+ TaskStore,
11
+ THREAD_ITEM,
12
+ type TaskRecord,
13
+ } from "../lib/agents-protocol.ts";
14
+
15
+ // Gentle Agents protocol: the child pi process streams RPC events; the host
16
+ // normalizes them into small typed deltas, applies them to an append-only
17
+ // thread, and notifies only the listeners of the task that changed.
18
+
19
+ function record(overrides: Partial<TaskRecord> = {}): TaskRecord {
20
+ return {
21
+ id: "t1",
22
+ agent: "gentle-ai-explore",
23
+ mode: "task",
24
+ prompt: "Map the repo",
25
+ label: "map the repo",
26
+ cwd: "/repo",
27
+ parentSessionId: "s1",
28
+ status: TASK_STATUS.QUEUED,
29
+ createdAt: 1000,
30
+ startedAt: null,
31
+ endedAt: null,
32
+ model: "openai-codex/gpt-5.6-terra",
33
+ thinking: "high",
34
+ sessionPath: null,
35
+ error: null,
36
+ result: null,
37
+ lastStep: "queued",
38
+ lastActivityAt: 1000,
39
+ turns: 0,
40
+ toolCalls: 0,
41
+ tokens: 0,
42
+ cost: 0,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ test("normalizeRpcEvent maps pi RPC events to task deltas and ignores the rest", () => {
48
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Hi" } }), [{ type: TASK_EVENT.TEXT, text: "Hi" }]);
49
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "hmm" } }), [{ type: TASK_EVENT.THINKING, text: "hmm" }]);
50
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_start", toolCallId: "c1", toolName: "bash", args: { command: "ls" } }), [{ type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { command: "ls" } }]);
51
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_update", toolCallId: "c1", toolName: "bash", partialResult: { content: [{ type: "text", text: "a\nb" }] } }), [{ type: TASK_EVENT.TOOL_UPDATE, callId: "c1", output: "a\nb" }]);
52
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_end", toolCallId: "c1", toolName: "bash", isError: true, result: { content: [{ type: "text", text: "boom" }] } }), [{ type: TASK_EVENT.TOOL_END, callId: "c1", output: "boom", isError: true }]);
53
+ assert.deepEqual(normalizeRpcEvent({ type: "turn_end" }), [{ type: TASK_EVENT.TURN_END }]);
54
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done." }], stopReason: "stop" }] }), [{ type: TASK_EVENT.AGENT_END, text: "done.", outcome: "success" }]);
55
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error with untrusted provider payload" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "error", diagnostic: "assistant reported an error" }]);
56
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "aborted" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "aborted", diagnostic: "assistant aborted" }]);
57
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "stop" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "empty", diagnostic: "assistant returned no final report" }]);
58
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_settled" }), [{ type: TASK_EVENT.AGENT_SETTLED }]);
59
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "error", reason: "error", error: { message: "rate limited" } } }), [{ type: TASK_EVENT.ERROR, message: "rate limited" }]);
60
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u1", method: "confirm", title: "Delete?" }), [{ type: TASK_EVENT.ASK, request: { id: "u1", method: "confirm", title: "Delete?" } }]);
61
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u2", method: "setStatus", statusKey: "mcp" }), [], "fire-and-forget UI requests never count as questions");
62
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u3", method: "notify", message: "hi" }), []);
63
+ assert.deepEqual(normalizeRpcEvent({ type: "auto_retry_start", attempt: 1, maxAttempts: 3 }), [{ type: TASK_EVENT.NOTE, text: "retrying (1/3)" }]);
64
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 9621, cost: { total: 0.0193 } } } }), [{ type: TASK_EVENT.USAGE, tokens: 9621, cost: 0.0193 }]);
65
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { role: "user" } }), []);
66
+ assert.deepEqual(normalizeRpcEvent({ type: "queue_update" }), []);
67
+ assert.deepEqual(normalizeRpcEvent("garbage"), []);
68
+ });
69
+
70
+ test("response observations are opt-in, finalized, field-specific and content-free", () => {
71
+ const message = { role: "assistant", provider: "openai", model: "gpt-4o", responseModel: "gpt-4o-2024-08-06",
72
+ providerThinkingLevel: "high", stopReason: "stop", content: [{ type: "text", text: "private response" }],
73
+ errorMessage: "private error", responseId: "private id", modelVersion: "invented",
74
+ usage: { input: 12, output: 4, cacheRead: 0, cacheWrite: -1, totalTokens: 16, reasoning: 2 } };
75
+ const raw = { type: "message_end", message };
76
+ assert.deepEqual(normalizeRpcEvent(raw), [{ type: TASK_EVENT.USAGE, tokens: 16, cost: 0 }]);
77
+ const events = normalizeRpcEvent(raw, { observeResponses: true });
78
+ assert.equal(events.length, 2);
79
+ const observation = events.find((event) => event.type === "response_observation")?.observation;
80
+ assert.ok(observation);
81
+ assert.deepEqual(observation.provider, { state: "observed", value: "openai" });
82
+ assert.deepEqual(observation.model, { state: "observed", value: "gpt-4o" });
83
+ assert.deepEqual(observation.responseModel, { state: "observed", value: message.responseModel });
84
+ assert.deepEqual(observation.providerThinkingLevel, { state: "observed", value: "high" });
85
+ assert.deepEqual(observation.selected, { provider: { state: "unavailable" }, model: { state: "unavailable" }, effort: { state: "unavailable" } });
86
+ assert.deepEqual(observation.tokens, { input: { state: "reported", value: 12 }, output: { state: "reported", value: 4 },
87
+ cacheRead: { state: "unavailable" }, cacheWrite: { state: "unavailable" }, totalTokens: { state: "reported", value: 16 },
88
+ reasoning: { state: "reported", value: 2 } });
89
+ assert.doesNotMatch(JSON.stringify(observation), /private|invented|modelVersion|content|responseId/);
90
+ for (const stopReason of ["stop", "length", "toolUse", "error", "aborted"]) {
91
+ const [event] = normalizeRpcEvent({ type: "message_end", message: { role: "assistant", stopReason } }, { observeResponses: true });
92
+ assert.ok(event?.type === "response_observation", "missing usage still exposes a response");
93
+ assert.equal(event.observation.stopReason, stopReason);
94
+ }
95
+ for (const stopReason of ["pending", "deferred", undefined, "private error"]) {
96
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { role: "assistant", stopReason } }, { observeResponses: true }), []);
97
+ }
98
+ for (const type of ["message_start", "message_update", "turn_end", "agent_end"]) {
99
+ assert.ok(!normalizeRpcEvent({ type, message, messages: [message] }, { observeResponses: true }).some((event) => event.type === "response_observation"));
100
+ }
101
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { ...message, role: "toolResult" } }, { observeResponses: true }), []);
102
+ });
103
+
104
+ test("response observations reject malformed counters and unbounded metadata without coercion", () => {
105
+ for (const value of [0, -1, 1.5, NaN, Infinity, "12", null, undefined, 1_000_000_001]) {
106
+ const [event] = normalizeRpcEvent({ type: "message_end", message: { role: "assistant", stopReason: "error",
107
+ provider: "x".repeat(33), model: "x".repeat(129), responseModel: "private\ntext", providerThinkingLevel: {},
108
+ usage: { input: value, output: value, cacheRead: value, cacheWrite: value, totalTokens: value, reasoning: value } } }, { observeResponses: true }).filter((event) => event.type === "response_observation");
109
+ assert.ok(event);
110
+ for (const field of Object.values(event.observation.tokens)) assert.deepEqual(field, { state: "unavailable" });
111
+ for (const field of ["provider", "model", "responseModel", "providerThinkingLevel"] as const) assert.deepEqual(event.observation[field], { state: "unavailable" });
112
+ }
113
+ });
114
+
115
+ test("taskLabel prefers an explicit label and otherwise takes the prompt's first sentence", () => {
116
+ assert.equal(taskLabel("Map the repo. Then report.", " map the repo "), "map the repo");
117
+ assert.equal(taskLabel("Repeat a fresh read-only exploration of lib. Own runtime architecture: extensions, hooks."), "Repeat a fresh read-only exploration of lib");
118
+ assert.equal(taskLabel("\n\nFirst line here:\nsecond"), "First line here");
119
+ assert.equal(taskLabel(`${"x".repeat(100)} tail`).length, 72);
120
+ assert.equal(taskLabel("\x1b[31mred\x1b[0m"), "red");
121
+ });
122
+
123
+ test("applyTaskEvent appends incrementally: text deltas merge, tool output is replaced, items stay bounded", () => {
124
+ let thread = emptyThread({ maxItems: 3, maxOutputChars: 9 });
125
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "Hel" });
126
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "lo" });
127
+ assert.deepEqual(thread.items, [{ kind: THREAD_ITEM.TEXT, text: "Hello" }]);
128
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { command: "ls" } });
129
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_UPDATE, callId: "c1", output: "line one\nline two" });
130
+ assert.equal(thread.items[1].kind, THREAD_ITEM.TOOL);
131
+ assert.equal((thread.items[1] as { output: string }).output, "…line two", "tool output keeps the tail");
132
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_END, callId: "c1", output: "ok", isError: false });
133
+ assert.deepEqual(thread.items[1], { kind: THREAD_ITEM.TOOL, callId: "c1", name: "bash", args: { command: "ls" }, output: "ok", running: false, isError: false });
134
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.NOTE, text: "retrying (1/3)" });
135
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "After" });
136
+ assert.equal(thread.items.length, 3);
137
+ assert.equal(thread.dropped, 1, "the oldest item made room");
138
+ assert.equal(thread.items[0].kind, THREAD_ITEM.TOOL);
139
+ assert.equal(thread.version, 7);
140
+ });
141
+
142
+ test("applyTaskEvent sanitizes text, ignores updates for unknown tools, and records asks as notes", () => {
143
+ let thread = emptyThread();
144
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "\x1b[31mred\x1b[0m" });
145
+ assert.deepEqual(thread.items, [{ kind: THREAD_ITEM.TEXT, text: "red" }]);
146
+ const before = thread;
147
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_UPDATE, callId: "missing", output: "x" });
148
+ assert.strictEqual(thread, before);
149
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.ASK, request: { id: "u1", method: "confirm", title: "Delete?" } });
150
+ assert.deepEqual(thread.items[1], { kind: THREAD_ITEM.NOTE, text: "asked: Delete?" });
151
+ });
152
+
153
+ test("TaskStore notifies only the listeners of the task that changed and keeps summaries cheap", () => {
154
+ const store = new TaskStore();
155
+ store.add(record({ id: "a" }));
156
+ store.add(record({ id: "b", agent: "worker" }));
157
+ const seenA: string[] = [];
158
+ const seenB: string[] = [];
159
+ const summaries: string[] = [];
160
+ const offA = store.subscribe("a", (task) => seenA.push(`${task.status}:${task.lastStep}`));
161
+ store.subscribe("b", (task) => seenB.push(task.status));
162
+ store.subscribeSummary((summary) => summaries.push(`${summary.running}/${summary.queued}/${summary.finished}`));
163
+ store.update("a", { status: TASK_STATUS.RUNNING, startedAt: 2000, lastStep: "starting" });
164
+ store.apply("a", { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: {} }, 2500);
165
+ assert.deepEqual(seenA, ["running:starting", "running:bash"]);
166
+ assert.deepEqual(seenB, []);
167
+ assert.deepEqual(summaries, ["1/1/0"], "an event inside a task never touches the summary");
168
+ assert.equal(store.get("a")?.toolCalls, 1);
169
+ assert.equal(store.get("a")?.lastActivityAt, 2500);
170
+ assert.equal(store.thread("a").items.length, 1);
171
+ offA();
172
+ store.update("a", { status: TASK_STATUS.COMPLETED, endedAt: 3000 });
173
+ assert.equal(seenA.length, 2, "unsubscribed listener stays quiet");
174
+ assert.deepEqual(summaries, ["1/1/0", "0/1/1"]);
175
+ assert.deepEqual(store.list("s1").map((task) => task.id), ["a", "b"], "newest activity first");
176
+ assert.equal(store.get("missing"), undefined);
177
+ assert.deepEqual(store.thread("missing"), emptyThread());
178
+ });
179
+
180
+ test("TaskStore.apply moves the task to waiting on ask, back to running on any later event, and counts turns", () => {
181
+ const store = new TaskStore();
182
+ store.add(record({ id: "a", status: TASK_STATUS.RUNNING }));
183
+ store.apply("a", { type: TASK_EVENT.ASK, request: { id: "u1", method: "input", title: "Name?" } }, 1);
184
+ assert.equal(store.get("a")?.status, TASK_STATUS.WAITING);
185
+ assert.equal(store.get("a")?.lastStep, "asked: Name?");
186
+ store.apply("a", { type: TASK_EVENT.TEXT, text: "thanks" }, 2);
187
+ assert.equal(store.get("a")?.status, TASK_STATUS.RUNNING);
188
+ store.apply("a", { type: TASK_EVENT.TURN_END }, 3);
189
+ store.apply("a", { type: TASK_EVENT.TURN_END }, 4);
190
+ assert.equal(store.get("a")?.turns, 2);
191
+ store.apply("a", { type: TASK_EVENT.USAGE, tokens: 100, cost: 0.5 }, 4);
192
+ store.apply("a", { type: TASK_EVENT.USAGE, tokens: 50, cost: 0.25 }, 4);
193
+ assert.equal(store.get("a")?.tokens, 150);
194
+ assert.equal(store.get("a")?.cost, 0.75);
195
+ store.apply("a", { type: TASK_EVENT.AGENT_END, text: "final answer", outcome: "success" }, 5);
196
+ assert.equal(store.get("a")?.result, "final answer");
197
+ assert.equal(store.get("a")?.status, TASK_STATUS.RUNNING, "agent_end alone does not finish: the runner decides");
198
+ });
@@ -0,0 +1,190 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts";
4
+ import { CHILD_MESSAGE_MAX_BYTES, ChildMessenger } from "../lib/agents-messaging.ts";
5
+ import { TaskStore } from "../lib/agents-protocol.ts";
6
+ import { AgentRunner } from "../lib/agents-runner.ts";
7
+ import { fakeChild } from "./agents-fake-child.ts";
8
+
9
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
10
+ const agent: AgentDefinition = { name: "worker", description: "", filePath: "/a.md", scope: "global", instructions: "", model: undefined, thinking: undefined, mode: undefined, tools: ["read"] };
11
+ const request = (mode = AGENT_MODE.BACKGROUND) => ({ agent, prompt: "work", label: undefined, context: undefined, mode, cwd: "/repo", parentSessionId: "s1", model: undefined, thinking: undefined, sessionDir: "/sessions", resumeSessionPath: undefined, env: {} });
12
+
13
+ test("ChildMessenger validates query replies and settles timeout, callback failure, and disconnect once", async () => {
14
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
15
+ const frames: Array<Record<string, unknown>> = [];
16
+ const timers: Array<() => void> = [];
17
+ let callback: ((error: Error | null) => void) | undefined;
18
+ const endpoint = {
19
+ send: (frame: Record<string, unknown>, done?: (error: Error | null) => void) => { frames.push(frame); callback = done; return false; },
20
+ on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]),
21
+ };
22
+ const messenger = new ChildMessenger(endpoint, (fn) => { timers.push(fn); return () => {}; });
23
+ const pending = messenger.query("which file?");
24
+ assert.deepEqual(frames, [{ id: "q1", kind: "query", message: "which file?" }]);
25
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q1", kind: "reply", message: "\uD800" });
26
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q1", kind: "reply", message: "x".repeat(CHILD_MESSAGE_MAX_BYTES + 1) });
27
+ callback?.(new Error("send failed"));
28
+ await assert.rejects(pending, /send failed/);
29
+ const timeout = messenger.query("timeout");
30
+ timers.at(-1)!();
31
+ await assert.rejects(timeout, /timed out/);
32
+ const disconnected = messenger.query("disconnect");
33
+ for (const listener of listeners.get("disconnect") ?? []) listener({});
34
+ await assert.rejects(disconnected, /closed/);
35
+ });
36
+
37
+ test("ChildMessenger resolves one strictly correlated query reply", async () => {
38
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
39
+ const frames: Array<Record<string, unknown>> = [];
40
+ const endpoint = {
41
+ send: (frame: Record<string, unknown>) => { frames.push(frame); return true; },
42
+ on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]),
43
+ };
44
+ const messenger = new ChildMessenger(endpoint, () => () => {});
45
+ const pending = messenger.query("Where?");
46
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "reply", message: "forged" });
47
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q1", kind: "reply", message: "src/a.ts", extra: true });
48
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q1", kind: "reply", message: "src/a.ts" });
49
+ assert.deepEqual(frames, [{ id: "q1", kind: "query", message: "Where?" }]);
50
+ assert.equal(await pending, "src/a.ts");
51
+ });
52
+
53
+ test("ChildMessenger rejects an empty reply error and ignores mixed reply fields", async () => {
54
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
55
+ const timers: Array<{ cancelled: boolean; fn: () => void }> = [];
56
+ const messenger = new ChildMessenger({
57
+ send: () => true,
58
+ on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener as (value: Record<string, unknown>) => void]),
59
+ }, (fn) => {
60
+ const timer = { cancelled: false, fn };
61
+ timers.push(timer);
62
+ return () => { timer.cancelled = true; };
63
+ });
64
+ const emptyError = messenger.query("empty error");
65
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q1", kind: "reply", error: "" });
66
+ await assert.rejects(emptyError, /parent rejected query/);
67
+ assert.equal(timers[0]?.cancelled, true, "a rejected reply cancels its deadline");
68
+ const mixed = messenger.query("mixed fields");
69
+ for (const listener of listeners.get("message") ?? []) listener({ id: "q2", kind: "reply", message: "answer", error: "also error" });
70
+ assert.equal(timers[1]?.cancelled, false, "mixed reply fields cannot settle a query");
71
+ timers[1]?.fn();
72
+ await assert.rejects(mixed, /timed out/);
73
+ });
74
+
75
+ test("runner sends bounded static query rejection frames that settle the child before timeout", async () => {
76
+ const child = fakeChild();
77
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
78
+ const timers: Array<{ cancelled: boolean; fn: () => void }> = [];
79
+ child.child.send = (frame, done) => {
80
+ child.sent.push(frame);
81
+ for (const listener of listeners.get("message") ?? []) listener(frame);
82
+ done?.(null);
83
+ return true;
84
+ };
85
+ const messenger = new ChildMessenger({
86
+ send: (frame) => { child.message(frame); return true; },
87
+ on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener as (value: Record<string, unknown>) => void]),
88
+ }, (fn) => {
89
+ const timer = { cancelled: false, fn };
90
+ timers.push(timer);
91
+ return () => { timer.cancelled = true; };
92
+ });
93
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: () => () => {}, pi: { command: "pi", args: [] } }, { askUser: async () => ({ cancelled: true }), onQuery: () => { throw new Error(`\uD800${"x".repeat(CHILD_MESSAGE_MAX_BYTES)}`); } });
94
+ runner.run(request());
95
+ await tick();
96
+ const pending = messenger.query("reject me");
97
+ timers[0]?.fn();
98
+ await assert.rejects(pending, /parent rejected query/);
99
+ assert.deepEqual(child.sent.at(-1), { id: "q1", kind: "reply", error: "parent rejected query" });
100
+ assert.equal(timers[0]?.cancelled, true, "the child reply settles before its deadline");
101
+ });
102
+
103
+ test("a malformed query with a valid q ID gets a reply that clears the matching child query", async () => {
104
+ const child = fakeChild();
105
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
106
+ const timers: Array<{ cancelled: boolean; fn: () => void }> = [];
107
+ child.child.send = (frame, done) => {
108
+ child.sent.push(frame);
109
+ for (const listener of listeners.get("message") ?? []) listener(frame);
110
+ done?.(null);
111
+ return true;
112
+ };
113
+ const messenger = new ChildMessenger({
114
+ send: (frame) => { child.message(frame); return true; },
115
+ on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener as (value: Record<string, unknown>) => void]),
116
+ }, (fn) => {
117
+ const timer = { cancelled: false, fn };
118
+ timers.push(timer);
119
+ return () => { timer.cancelled = true; };
120
+ });
121
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: () => () => {}, pi: { command: "pi", args: [] } }, { askUser: async () => ({ cancelled: true }), onQuery: () => {} });
122
+ runner.run(request());
123
+ await tick();
124
+ const pending = messenger.query("valid first");
125
+ child.message({ id: "q1", kind: "query", message: "malformed", extra: true });
126
+ timers[0]?.fn();
127
+ await assert.rejects(pending, /invalid child IPC frame/);
128
+ assert.deepEqual(child.sent.at(-1), { id: "q1", kind: "reply", error: "invalid child IPC frame" });
129
+ assert.equal(timers[0]?.cancelled, true);
130
+ });
131
+
132
+ test("background queries keep correlation, bounds, and ownership before replying only over IPC", async () => {
133
+ const child = fakeChild();
134
+ const queries: Array<{ taskId: string; requestId: string; message: string }> = [];
135
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: () => () => {}, pi: { command: "pi", args: [] } }, {
136
+ askUser: async () => ({ cancelled: true }), onQuery: (task, requestId, message) => queries.push({ taskId: task.id, requestId, message }),
137
+ });
138
+ const task = runner.run(request());
139
+ await tick();
140
+ for (const [id, message] of [["q1", "Which file?"], ["q2", "two"], ["q3", "three"], ["q4", "four"], ["q5", "five"], ["q1", "duplicate"]]) child.message({ id, kind: "query", message });
141
+ await tick();
142
+ assert.deepEqual(queries, ["q1", "q2", "q3", "q4"].map((requestId, index) => ({ taskId: task.id, requestId, message: ["Which file?", "two", "three", "four"][index] })));
143
+ assert.deepEqual(child.sent.slice(-2), [{ id: "q5", kind: "reply", error: "too many pending parent queries" }, { id: "q1", kind: "reply", error: "duplicate query request" }]);
144
+ assert.equal(await runner.reply(task.id, "q1", "src/a.ts", "s1"), true);
145
+ assert.equal(await runner.reply(task.id, "q1", "again", "s1"), false);
146
+ assert.equal(await runner.reply(task.id, "q2", "other", "s2"), false);
147
+ assert.equal(await runner.reply(task.id, "q2", "x".repeat(CHILD_MESSAGE_MAX_BYTES + 1), "s1"), false);
148
+ assert.deepEqual(child.sent.at(-1), { id: "q1", kind: "reply", message: "src/a.ts" });
149
+ });
150
+
151
+ test("a throwing query hook rolls back ownership before one error and never schedules a second reply", async () => {
152
+ const child = fakeChild();
153
+ const timers: Array<{ fn: () => void; ms: number }> = [];
154
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; }, pi: { command: "pi", args: [] } }, { askUser: async () => ({ cancelled: true }), onQuery: () => { throw new Error("host callback failed"); } });
155
+ const task = runner.run(request());
156
+ await tick();
157
+ child.message({ id: "q1", kind: "query", message: "throw" });
158
+ assert.deepEqual(child.sent.at(-1), { id: "q1", kind: "reply", error: "parent rejected query" });
159
+ assert.equal(await runner.reply(task.id, "q1", "late", "s1"), false);
160
+ timers.at(-1)?.fn();
161
+ assert.equal(child.sent.filter((frame) => frame.id === "q1").length, 1);
162
+ });
163
+
164
+ test("query expiry, IPC revocation, delayed callbacks, and foreground admission settle once", async () => {
165
+ const child = fakeChild();
166
+ const timers: Array<{ fn: () => void; ms: number }> = [];
167
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; }, pi: { command: "pi", args: [] } }, { askUser: async () => ({ cancelled: true }), onQuery: () => {} });
168
+ const task = runner.run(request());
169
+ await tick();
170
+ child.message({ id: "q1", kind: "query", message: "wait" });
171
+ assert.equal(timers.at(-1)?.ms, 30_000);
172
+ let callback: ((error: Error | null) => void) | undefined;
173
+ child.child.send = (_frame, done) => { callback = done; return false; };
174
+ const pending = runner.reply(task.id, "q1", "late", "s1");
175
+ timers.at(-1)?.fn();
176
+ assert.equal(await pending, false);
177
+ callback?.(null);
178
+ assert.equal(await runner.reply(task.id, "q1", "again", "s1"), false);
179
+ child.message({ id: "q2", kind: "query", message: "disconnect" });
180
+ child.child.disconnect?.();
181
+ assert.equal(await runner.reply(task.id, "q2", "no", "s1"), false);
182
+ const foregroundChild = fakeChild();
183
+ let foregroundQueries = 0;
184
+ const foreground = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => foregroundChild.child, now: () => 1, schedule: () => () => {}, pi: { command: "pi", args: [] } }, { askUser: async () => ({ cancelled: true }), onQuery: () => { foregroundQueries += 1; } });
185
+ foreground.run(request(AGENT_MODE.TASK));
186
+ await tick();
187
+ foregroundChild.message({ id: "q1", kind: "query", message: "not yet" });
188
+ await tick();
189
+ assert.equal(foregroundQueries, 1);
190
+ });
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { measureAgentsViewLayout } from "../lib/agents-view-layout.ts";
5
+
6
+ const widths = [0, 1, 2, 11, 12, 59, 60, 90];
7
+
8
+ for (const rows of [0, 1, 2]) {
9
+ test(`Agents layout safely bounds ${rows}-row terminals`, () => {
10
+ for (const width of widths) {
11
+ const layout = measureAgentsViewLayout(width, rows);
12
+ assert.equal(layout.height, rows);
13
+ assert.ok(layout.width >= 0 && layout.width <= width);
14
+ assert.ok(layout.bodyRows >= 0);
15
+ assert.ok(layout.listWidth >= 0 && layout.threadWidth >= 0);
16
+ }
17
+ });
18
+ }
19
+
20
+ test("Agents layout reserves fallback for tiny terminals, a single viewport for narrow terminals, and panes at 60 cells", () => {
21
+ for (const width of [0, 1, 2, 11]) assert.equal(measureAgentsViewLayout(width, 8).mode, "fallback");
22
+ for (const height of [0, 1, 2]) assert.equal(measureAgentsViewLayout(80, height).mode, "fallback");
23
+ for (const width of [12, 59]) {
24
+ const narrow = measureAgentsViewLayout(width, 3);
25
+ assert.equal(narrow.mode, "narrow");
26
+ assert.equal(narrow.bodyRows, 0);
27
+ assert.equal(narrow.footerY, 1);
28
+ }
29
+ const layout = measureAgentsViewLayout(60, 8);
30
+ assert.equal(layout.mode, "panes");
31
+ assert.equal(layout.height, 8);
32
+ assert.equal(layout.bodyRows, 5);
33
+ assert.equal(layout.footerY, 6);
34
+ assert.equal(layout.listX, 2);
35
+ assert.equal(layout.threadX, layout.listX + layout.listWidth + 3);
36
+ assert.ok(layout.threadX + layout.threadWidth + 1 <= layout.width);
37
+ const fullscreen = measureAgentsViewLayout(60, 8, true);
38
+ assert.equal(fullscreen.mode, "narrow");
39
+ assert.equal(fullscreen.threadWidth, 56);
40
+ assert.equal(fullscreen.threadX, 2);
41
+ assert.equal(fullscreen.height, 8);
42
+ assert.equal(visibleWidth("寿司"), 4, "multibyte widths remain terminal-cell widths");
43
+ });