gentle-pi 2.5.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCompletionQueue, STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
4
+
5
+ // The completion queue owns Gentle Agents' pending background completions so a
6
+ // completion is delivered at most once, promptly, and never replays state the
7
+ // parent already consumed (issue #867).
8
+
9
+ interface FakeTask {
10
+ id: string;
11
+ }
12
+
13
+ const task = (id: string): FakeTask => ({ id });
14
+
15
+ test("STALE_COMPLETION_MS is pinned so tests can rely on the boundary", () => {
16
+ assert.equal(STALE_COMPLETION_MS, 90_000);
17
+ });
18
+
19
+ test("a pending completion is delivered exactly once, in settle order", () => {
20
+ const queue = createCompletionQueue<FakeTask>();
21
+ queue.enqueue(task("t1"), 1000);
22
+ queue.enqueue(task("t2"), 2000);
23
+ const first = queue.takeDeliverable(2000);
24
+ assert.deepEqual(first.map((entry) => entry.task.id), ["t1", "t2"]);
25
+ assert.equal(first[0]!.settledAt, 1000);
26
+ assert.equal(first[0]!.stale, false, "a completion inside the stale window is fresh");
27
+ assert.deepEqual(queue.takeDeliverable(2000), [], "a taken entry is never returned twice");
28
+ });
29
+
30
+ test("duplicate enqueue for a pending task id is a no-op", () => {
31
+ const queue = createCompletionQueue<FakeTask>();
32
+ queue.enqueue(task("t1"), 1000);
33
+ queue.enqueue(task("t1"), 5000);
34
+ assert.deepEqual(queue.takeDeliverable(5000).map((entry) => entry.task.id), ["t1"]);
35
+ });
36
+
37
+ test("duplicate enqueue for a delivered task id is a no-op", () => {
38
+ const queue = createCompletionQueue<FakeTask>();
39
+ queue.enqueue(task("t1"), 1000);
40
+ assert.equal(queue.takeDeliverable(1000).length, 1);
41
+ queue.enqueue(task("t1"), 5000);
42
+ assert.deepEqual(queue.takeDeliverable(5000), []);
43
+ });
44
+
45
+ test("duplicate enqueue for a consumed task id is a no-op", () => {
46
+ const queue = createCompletionQueue<FakeTask>();
47
+ queue.consume("t1");
48
+ queue.enqueue(task("t1"), 1000);
49
+ assert.deepEqual(queue.takeDeliverable(5000), []);
50
+ });
51
+
52
+ test("a consumed entry is dropped and never returned", () => {
53
+ const queue = createCompletionQueue<FakeTask>();
54
+ queue.enqueue(task("t1"), 1000);
55
+ queue.enqueue(task("t2"), 1000);
56
+ queue.consume("t1");
57
+ assert.deepEqual(queue.takeDeliverable(2000).map((entry) => entry.task.id), ["t2"]);
58
+ });
59
+
60
+ test("consume remembers unknown ids so a later completion is suppressed", () => {
61
+ const queue = createCompletionQueue<FakeTask>();
62
+ queue.consume("t9");
63
+ queue.enqueue(task("t9"), 1000);
64
+ assert.deepEqual(queue.takeDeliverable(2000), []);
65
+ });
66
+
67
+ test("consume is idempotent", () => {
68
+ const queue = createCompletionQueue<FakeTask>();
69
+ queue.consume("t1");
70
+ queue.consume("t1");
71
+ queue.enqueue(task("t1"), 1000);
72
+ assert.deepEqual(queue.takeDeliverable(2000), []);
73
+ });
74
+
75
+ test("an entry is stale once now - settledAt reaches STALE_COMPLETION_MS", () => {
76
+ const fresh = createCompletionQueue<FakeTask>();
77
+ fresh.enqueue(task("t1"), 1000);
78
+ assert.equal(fresh.takeDeliverable(1000 + STALE_COMPLETION_MS - 1)[0]!.stale, false);
79
+ const stale = createCompletionQueue<FakeTask>();
80
+ stale.enqueue(task("t1"), 1000);
81
+ assert.equal(stale.takeDeliverable(1000 + STALE_COMPLETION_MS)[0]!.stale, true);
82
+ });
83
+
84
+ test("dropAll discards pending, consumed, and delivered state", () => {
85
+ const queue = createCompletionQueue<FakeTask>();
86
+ queue.enqueue(task("t1"), 1000);
87
+ assert.equal(queue.takeDeliverable(1000).length, 1, "delivered bookkeeping exists");
88
+ queue.enqueue(task("t2"), 1000);
89
+ queue.consume("t3");
90
+ queue.dropAll();
91
+ assert.deepEqual(queue.takeDeliverable(5000), [], "nothing pending survives dropAll");
92
+ queue.enqueue(task("t1"), 5000);
93
+ assert.deepEqual(queue.takeDeliverable(5000).map((entry) => entry.task.id), ["t1"], "the queue is fresh after dropAll");
94
+ });
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import test, { after } from "node:test";
6
6
  import {
7
7
  AGENT_MODE,
8
+ THINKING_LEVEL,
8
9
  agentDirectories,
9
10
  discoverAgents,
10
11
  loadAgentsConfig,
@@ -14,6 +15,7 @@ import {
14
15
  parseModelRef,
15
16
  resolveAgentProfile,
16
17
  } from "../lib/agents-config.ts";
18
+ import { THINKING_LEVELS } from "../lib/model-routing-authority.ts";
17
19
 
18
20
  // Gentle Agents configuration: markdown agent definitions (the same files
19
21
  // gentle-ai installs) and subagents.json, both parsed without touching pi.
@@ -77,6 +79,66 @@ test("parseAgentDefinition rejects unknown thinking levels, modes, and empty bod
77
79
  assert.match((parseAgentDefinition("---\nname: a\n---\n \n", "/a.md", "global") as { error: string }).error, /no instructions/);
78
80
  });
79
81
 
82
+ test("runtime thinking levels stay aligned with model routing authority", () => {
83
+ assert.deepEqual(Object.values(THINKING_LEVEL), THINKING_LEVELS);
84
+ assert.equal(THINKING_LEVEL.MAX, "max");
85
+ });
86
+
87
+ test("parseAgentDefinition accepts max through every thinking alias", () => {
88
+ for (const field of ["thinking", "effort", "thinking_level"]) {
89
+ const agent = parseAgentDefinition(`---\nname: worker\n${field}: max\n---\nbody`, "/worker.md", "global");
90
+ assert.ok(!("error" in agent), `${field} must accept max`);
91
+ assert.equal(agent.thinking, "max");
92
+ }
93
+ });
94
+
95
+ test("discoverAgents retains max definitions instead of falling back to medium", () => {
96
+ const home = join(root, "max-home");
97
+ const cwd = join(root, "max-project");
98
+ const agentHome = join(home, ".pi", "agent");
99
+ for (const dir of ["agents", "subagents"]) mkdirSync(join(agentHome, dir), { recursive: true });
100
+ writeFileSync(join(agentHome, "agents", "worker.md"), "---\nname: worker\nthinking: medium\n---\nlegacy worker");
101
+ writeFileSync(join(agentHome, "subagents", "worker.md"), "---\nname: worker\nthinking: max\n---\nselected worker");
102
+ writeFileSync(join(agentHome, "subagents.json"), JSON.stringify({ model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna", effort: "max" } } }));
103
+ const { agents, errors } = discoverAgents({ cwd, home });
104
+ assert.deepEqual(errors, []);
105
+ assert.equal(agents.length, 1);
106
+ assert.equal(agents[0].thinking, "max");
107
+ assert.equal(agents[0].instructions, "selected worker");
108
+ const resolved = resolveAgentProfile(agents[0], loadAgentsConfig({ cwd, home }));
109
+ assert.equal(resolved.model?.id, "gpt-5.6-luna");
110
+ assert.equal(resolved.thinking, "max");
111
+ assert.equal(resolved.source.thinking, "profile");
112
+ });
113
+
114
+ test("max defaults and model profiles preserve routing precedence", () => {
115
+ const bare = parseAgentDefinition("---\nname: worker\n---\nbody", "/worker.md", "global");
116
+ const medium = parseAgentDefinition("---\nname: worker\nthinking: medium\n---\nbody", "/worker.md", "global");
117
+ assert.ok(!("error" in bare));
118
+ assert.ok(!("error" in medium));
119
+ for (const field of ["default_effort", "default_thinking_level", "default_thinking"]) {
120
+ const config = parseAgentsConfig({ [field]: "max" }, undefined);
121
+ assert.equal(config.defaultThinking, "max");
122
+ assert.equal(resolveAgentProfile(bare, config).thinking, "max");
123
+ assert.equal(resolveAgentProfile(bare, config).source.thinking, "default");
124
+ assert.equal(resolveAgentProfile(medium, config).thinking, "medium");
125
+ }
126
+ for (const field of ["effort", "thinking"]) {
127
+ const global = { model_profiles: { worker: { [field]: "max" } } };
128
+ const config = parseAgentsConfig(global, { model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna" } } });
129
+ assert.equal(config.modelProfiles.worker.thinking, "max");
130
+ assert.equal(resolveAgentProfile(medium, config).thinking, "max");
131
+ assert.equal(resolveAgentProfile(medium, config).source.thinking, "profile");
132
+ }
133
+ const override = parseAgentsConfig(
134
+ { model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna", effort: "high" } } },
135
+ { model_profiles: { worker: { effort: "max" } } },
136
+ );
137
+ assert.equal(override.modelProfiles.worker.thinking, "max");
138
+ assert.equal(resolveAgentProfile(medium, override).thinking, "max");
139
+ assert.equal(resolveAgentProfile(medium, override).model?.id, "gpt-5.6-luna");
140
+ });
141
+
80
142
  test("discoverAgents merges the four directories with project over global and subagents over agents", () => {
81
143
  const home = join(root, "home");
82
144
  const cwd = join(root, "project");
@@ -12,6 +12,9 @@ export interface FakeChild {
12
12
  exit(code: number): void;
13
13
  fail(message: string): void;
14
14
  killed: string[];
15
+ sent: Array<Record<string, unknown>>;
16
+ disconnects: number;
17
+ message(event: Record<string, unknown>): void;
15
18
  }
16
19
 
17
20
  export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}): FakeChild {
@@ -20,6 +23,8 @@ export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}):
20
23
  const stdout = new PassThrough();
21
24
  const written: Array<Record<string, unknown>> = [];
22
25
  const killed: string[] = [];
26
+ const sent: Array<Record<string, unknown>> = [];
27
+ let disconnects = 0;
23
28
  let buffer = "";
24
29
  stdin.on("data", (chunk: Buffer) => {
25
30
  buffer += chunk.toString();
@@ -43,10 +48,19 @@ export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}):
43
48
  if (options.exitOnKill !== false) queueMicrotask(() => emitter.emit("exit", 0, signal ?? "SIGTERM"));
44
49
  return true;
45
50
  },
51
+ send: (message, callback) => {
52
+ sent.push(message);
53
+ callback?.(null);
54
+ return true;
55
+ },
56
+ disconnect: () => {
57
+ disconnects += 1;
58
+ emitter.emit("disconnect");
59
+ },
46
60
  on: (event, listener) => {
47
61
  emitter.on(event, listener);
48
62
  return child;
49
63
  },
50
64
  };
51
- return { child, written, killed, emit: (event) => stdout.write(`${JSON.stringify(event)}\n`), exit: (code) => emitter.emit("exit", code, null), fail: (message) => emitter.emit("error", new Error(message)) };
65
+ return { child, written, killed, sent, get disconnects() { return disconnects; }, message: (event) => emitter.emit("message", event), emit: (event) => stdout.write(`${JSON.stringify(event)}\n`), exit: (code) => emitter.emit("exit", code, null), fail: (message) => emitter.emit("error", new Error(message)) };
52
66
  }
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
5
+ import { AgentsView, SESSION_FINISHED_TTL_MS } 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, parentSessionId: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
11
+ return {
12
+ id,
13
+ agent: "worker",
14
+ mode: "task",
15
+ prompt: "p",
16
+ label: "p",
17
+ cwd: "/r",
18
+ parentSessionId,
19
+ status: TASK_STATUS.RUNNING,
20
+ createdAt: 1000,
21
+ startedAt: 1000,
22
+ endedAt: null,
23
+ model: "gpt",
24
+ thinking: undefined,
25
+ sessionPath: "/sessions/child.jsonl",
26
+ error: null,
27
+ result: null,
28
+ lastStep: "working",
29
+ lastActivityAt: 1000,
30
+ turns: 0,
31
+ toolCalls: 0,
32
+ tokens: 0,
33
+ cost: 0,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function harness(rows = 12, now = () => 10_000) {
39
+ const store = new TaskStore();
40
+ const view = new AgentsView({
41
+ theme: plainTheme,
42
+ rows,
43
+ store,
44
+ sessionId: "current-session-id",
45
+ now,
46
+ onCancel: () => {},
47
+ onOpen: () => {},
48
+ onClose: () => {},
49
+ requestRender: () => {},
50
+ });
51
+ return { store, view };
52
+ }
53
+
54
+ function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "move"): TuiMouseEvent {
55
+ return { type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
56
+ }
57
+
58
+ test("AgentsView shows direct current children and groups only open orchestrators in the directory", () => {
59
+ const { store, view } = harness();
60
+ store.add(task("current", "current-session-id", { agent: "current-worker" }));
61
+ store.add(task("other", "other-session-123456", { agent: "other-worker" }));
62
+
63
+ const plain = view.render(100).map(stripAnsi).join("\n");
64
+ assert.doesNotMatch(plain, /Current orchestrator/);
65
+ assert.match(plain, /Subagent current-worker/);
66
+ assert.doesNotMatch(plain, /other-worker/, "the default scope excludes another parent session");
67
+ view.handleInput("a");
68
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Orchestrator other-se/);
69
+ assert.match(view.render(100).join("\n"), /Current orchestrator/);
70
+ view.dispose();
71
+ });
72
+
73
+ test("AgentsView never infers unknown open sessions and keeps manual directory expansion", () => {
74
+ const { store, view } = harness();
75
+ store.add(task("current", "current-session-id", { lastActivityAt: 500 }));
76
+ store.add(task("other", "other-session-123456", { lastActivityAt: 400 }));
77
+ store.add(task("unknown-active", "", { agent: "unknown-active", lastActivityAt: 300 }));
78
+ store.add(task("unknown-terminal", "", { agent: "unknown-terminal", status: TASK_STATUS.COMPLETED, endedAt: 9000, lastActivityAt: 200 }));
79
+ view.handleInput("a");
80
+
81
+ let lines = view.render(100);
82
+ let plain = lines.map(stripAnsi).join("\n");
83
+ assert.doesNotMatch(plain, /Unknown session|unknown-active|other-worker/, "retained origins are not presence evidence");
84
+ assert.doesNotMatch(plain, /unknown-terminal/, "a terminal-only group starts collapsed");
85
+ const terminalHeading = lines.map(stripAnsi).findIndex((line) => /Current orchestrator/.test(line));
86
+ assert.equal(view.handleMouse(mouse(4, terminalHeading, 100, lines.length, "click"))?.handled, true, "left click toggles a heading");
87
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "click collapses the local directory group");
88
+ store.update("unknown-terminal", { status: TASK_STATUS.FAILED });
89
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "manual collapse persists across unrelated updates");
90
+ view.handleMouse(mouse(4, terminalHeading, 100, lines.length, "click"));
91
+ store.update("unknown-terminal", { status: TASK_STATUS.CANCELLED });
92
+ assert.match(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "manual expansion persists across unrelated updates");
93
+ assert.doesNotMatch(view.render(100).join("\n"), /unknown-termina/, "unknown terminal history remains excluded");
94
+ view.dispose();
95
+ });
96
+
97
+ test("AgentsView keeps headings non-actionable and clears a hidden selected child thread", () => {
98
+ const { store, view } = harness();
99
+ store.add(task("first", "current-session-id", { createdAt: 2000, lastActivityAt: 200 }));
100
+ store.add(task("child", "current-session-id", { lastActivityAt: 100 }));
101
+ assert.equal(view.selectedTask()?.id, "first", "the first visible child starts selected");
102
+ view.handleInput("a");
103
+ view.handleInput("k");
104
+ view.handleInput("\x1b[D");
105
+ assert.equal(view.selectedTask(), undefined, "collapsing selects the heading instead of a hidden child");
106
+ assert.match(view.render(100).map(stripAnsi).join("\n"), /Select a task to inspect its thread/);
107
+ view.handleInput("\x1b[C");
108
+ assert.equal(view.selectedTask(), undefined, "expanding preserves heading focus until a child is selected");
109
+ view.handleInput("s");
110
+ view.handleInput("o");
111
+ view.dispose();
112
+ });
113
+
114
+ test("AgentsView removes terminal children immediately without deleting retained threads", () => {
115
+ let now = 10_000;
116
+ const { store, view } = harness(12, () => now);
117
+ store.add(task("live", "current-session-id", { agent: "retained" }));
118
+ store.add(task("other", "other-session", { agent: "excluded" }));
119
+ store.apply("live", { type: "text", text: "kept thread" }, now);
120
+ store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: now });
121
+ for (const elapsed of [0, SESSION_FINISHED_TTL_MS - 1]) {
122
+ now = 10_000 + elapsed;
123
+ const output = view.render(100).map(stripAnsi).join("\n");
124
+ assert.doesNotMatch(output, /Subagent retained|kept thread/);
125
+ assert.equal(store.thread("live").items.length, 1, "retained thread remains available outside the panel");
126
+ assert.doesNotMatch(output, /excluded/);
127
+ assert.equal(view.selectedTask(), undefined);
128
+ }
129
+ now = 10_000 + SESSION_FINISHED_TTL_MS;
130
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent retained/);
131
+ assert.equal(view.selectedTask(), undefined);
132
+ store.add(task("archive", "current-session-id", { agent: "archived", status: TASK_STATUS.COMPLETED, endedAt: now }));
133
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent archived/, "absent groups lose implicit expansion");
134
+ view.dispose();
135
+ });
136
+
137
+ test("AgentsView retains an idle open heading and explicit collapse after the final child finishes", () => {
138
+ const { store, view } = harness();
139
+ store.add(task("live", "current-session-id", { agent: "hidden" }));
140
+ view.handleInput("a");
141
+ view.render(100);
142
+ view.handleInput("k");
143
+ view.handleInput("\x1b[D");
144
+ store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: 10_000 });
145
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
146
+ view.handleInput("\x1b[C");
147
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
148
+ assert.match(view.render(100).join("\n"), /Current orchestrator.*0/);
149
+ store.add(task("replacement", "current-session-id", { agent: "replacement" }));
150
+ assert.match(view.render(100).join("\n"), /Subagent replacement/);
151
+ view.dispose();
152
+ });
153
+
154
+ test("AgentsView orders children by creation then ID, not renewed activity", () => {
155
+ const { store, view } = harness();
156
+ store.add(task("old", "current-session-id", { agent: "old", createdAt: 100 }));
157
+ store.add(task("b", "current-session-id", { agent: "new-b", createdAt: 200 }));
158
+ store.add(task("a", "current-session-id", { agent: "new-a", createdAt: 200 }));
159
+ store.update("old", { lastActivityAt: 20_000 });
160
+ const output = view.render(100).join("\n");
161
+ assert.ok(output.indexOf("Subagent new-a") < output.indexOf("Subagent new-b"));
162
+ assert.ok(output.indexOf("Subagent new-b") < output.indexOf("Subagent old"));
163
+ assert.equal(view.selectedTask()?.id, "old", "reordering preserves selection");
164
+ view.dispose();
165
+ });
166
+
167
+ test("AgentsView keeps its selected listener stable while another listener receives a stream update", () => {
168
+ const { store, view } = harness();
169
+ store.add(task("stream", "current-session-id"));
170
+ let notifications = 0;
171
+ const unsubscribe = store.subscribe("stream", () => {
172
+ notifications += 1;
173
+ if (notifications > 1) throw new Error("selected listener was re-added during notification");
174
+ });
175
+ store.apply("stream", { type: "text", text: "one" }, 2000);
176
+ assert.equal(notifications, 1, "a live listener set never revisits the same streaming update");
177
+ unsubscribe();
178
+ view.dispose();
179
+ });
@@ -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
+ });
@@ -67,6 +67,51 @@ test("normalizeRpcEvent maps pi RPC events to task deltas and ignores the rest",
67
67
  assert.deepEqual(normalizeRpcEvent("garbage"), []);
68
68
  });
69
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
+
70
115
  test("taskLabel prefers an explicit label and otherwise takes the prompt's first sentence", () => {
71
116
  assert.equal(taskLabel("Map the repo. Then report.", " map the repo "), "map the repo");
72
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");