mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -1,6 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
4
+ import { createRunTurn } from '../src/tui/engine/turn.mjs';
4
5
 
5
6
  // Minimal bag: drainPendingSteering only touches pending, the queue helpers,
6
7
  // and commitSteeringQueueEntries (which no-ops on disk when runtime.id is not
@@ -118,3 +119,181 @@ test('post-turn drain does not send queued slash command to model', async () =>
118
119
  assert.equal(bag.pending.length, 1, 'slash command remains for command dispatcher');
119
120
  assert.equal(bag.pending[0].content, '/clear');
120
121
  });
122
+
123
+ // Minimal store bag for createRunTurn: only the surface the streaming/steering
124
+ // finalize path touches. runtime.ask is a caller-supplied mock that drives the
125
+ // text-delta / steer-message callbacks.
126
+ function makeTurnBag(ask) {
127
+ let seq = 0;
128
+ const state = {
129
+ items: [],
130
+ stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
131
+ busy: false,
132
+ spinner: null,
133
+ thinking: null,
134
+ };
135
+ const itemIndexById = new Map();
136
+ const findIndexById = (id) => state.items.findIndex((it) => it.id === id);
137
+ const bag = {
138
+ runtime: { id: null, toolMode: 'auto', ask, abort: () => {} },
139
+ nextId: () => `id_${++seq}`,
140
+ tuiDebug: () => {},
141
+ LEAD_TURN_TIMEOUT_MS: 300000,
142
+ flags: { leadTurnEpoch: 0 },
143
+ pending: [],
144
+ itemIndexById,
145
+ getState: () => state,
146
+ set: (patch) => Object.assign(state, patch),
147
+ pushItem: (spec) => {
148
+ state.items = [...state.items, spec];
149
+ if (spec?.id != null) itemIndexById.set(spec.id, state.items.length - 1);
150
+ },
151
+ patchItem: (id, patch) => {
152
+ const idx = findIndexById(id);
153
+ if (idx < 0) return;
154
+ const items = state.items.slice();
155
+ items[idx] = { ...items[idx], ...patch };
156
+ state.items = items;
157
+ },
158
+ pushNotice: () => {},
159
+ pushUserOrSyntheticItem: (text) => {
160
+ state.items = [...state.items, { kind: 'user', id: `u_${++seq}`, text }];
161
+ },
162
+ markToolCallActive: () => {},
163
+ markToolCallDone: () => {},
164
+ clearActiveToolSummary: () => {},
165
+ agentStatusState: () => ({}),
166
+ routeState: () => ({}),
167
+ syncContextStats: () => {},
168
+ denyAllToolApprovals: () => {},
169
+ requestToolApproval: async () => ({ approved: false }),
170
+ patchToolCardResult: () => {},
171
+ flushToolResults: () => {},
172
+ flushDeferredExecutionPendingResumeKick: () => {},
173
+ drain: async () => {},
174
+ drainPendingSteering: () => [],
175
+ };
176
+ return { bag, getState: () => state };
177
+ }
178
+
179
+ test('onSteerMessage commits a streamed no-newline assistant tail into items', async () => {
180
+ // A terminal no-tool response streams a single line WITHOUT a trailing '\n',
181
+ // so no assistant row/currentAssistantId exists yet. A steering injection
182
+ // races finalization and must seal the pending tail instead of dropping it.
183
+ const TAIL = 'partial answer with no trailing newline';
184
+ const ask = async (_userText, opts) => {
185
+ opts.onTextDelta(TAIL);
186
+ opts.onSteerMessage('steer now');
187
+ return { result: { content: '' }, session: { messages: [] } };
188
+ };
189
+ const { bag, getState } = makeTurnBag(ask);
190
+ const runTurn = createRunTurn(bag);
191
+
192
+ await runTurn('do a thing');
193
+
194
+ const assistant = getState().items.find((it) => it.kind === 'assistant');
195
+ assert.ok(assistant, 'streamed no-newline tail must be committed as an assistant item');
196
+ assert.equal(assistant.text, TAIL);
197
+ assert.equal(assistant.streaming, false);
198
+ });
199
+
200
+ test('finalization does not duplicate a steer-committed tail when result.content repeats it', async () => {
201
+ // Same steer race, but the provider's final content equals the already-
202
+ // committed tail. Finalization must NOT re-emit it as a second item.
203
+ const TAIL = 'partial answer with no trailing newline';
204
+ const ask = async (_userText, opts) => {
205
+ opts.onTextDelta(TAIL);
206
+ opts.onSteerMessage('steer now');
207
+ return { result: { content: TAIL }, session: { messages: [] } };
208
+ };
209
+ const { bag, getState } = makeTurnBag(ask);
210
+ const runTurn = createRunTurn(bag);
211
+
212
+ await runTurn('do a thing');
213
+
214
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
215
+ assert.equal(assistants.length, 1, 'the committed tail must not be duplicated at finalize');
216
+ assert.equal(assistants[0].text, TAIL);
217
+ assert.equal(assistants[0].streaming, false);
218
+ });
219
+
220
+ test('finalization appends only the uncommitted remainder past a steer-committed tail', async () => {
221
+ // Provider content extends past the committed tail: only the new remainder
222
+ // becomes a fresh item, ordered after the committed segment (+ steering row).
223
+ const TAIL = 'partial answer with no trailing newline';
224
+ const REMAINDER = '\nmore text arriving after the steer';
225
+ const ask = async (_userText, opts) => {
226
+ opts.onTextDelta(TAIL);
227
+ opts.onSteerMessage('steer now');
228
+ return { result: { content: TAIL + REMAINDER }, session: { messages: [] } };
229
+ };
230
+ const { bag, getState } = makeTurnBag(ask);
231
+ const runTurn = createRunTurn(bag);
232
+
233
+ await runTurn('do a thing');
234
+
235
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
236
+ assert.equal(assistants.length, 2, 'committed tail + remainder are two distinct items');
237
+ assert.equal(assistants[0].text, TAIL, 'committed tail stays first, unchanged');
238
+ assert.equal(assistants[1].text, REMAINDER, 'only the uncommitted remainder is appended');
239
+ assert.equal(assistants[1].streaming, false);
240
+ const steerIdx = getState().items.findIndex((it) => it.kind === 'user' && it.text === 'steer now');
241
+ const remainderIdx = getState().items.findIndex((it) => it.kind === 'assistant' && it.text === REMAINDER);
242
+ assert.ok(steerIdx >= 0 && remainderIdx > steerIdx, 'remainder appends after the injected steering row');
243
+ });
244
+
245
+ // Two committed segments this turn: a prior preamble P, then a no-newline TAIL
246
+ // sealed by the steer race. The provider's final content may OMIT P — the
247
+ // per-segment strip must still peel BOTH out (a single concatenated 'P+TAIL'
248
+ // prefix would fail to match and duplicate TAIL after the steering row).
249
+ const P = 'preamble sealed before the tail';
250
+ const TAIL2 = 'terminal tail with no trailing newline';
251
+ function makeTwoSegmentAsk(finalContent) {
252
+ return async (_userText, opts) => {
253
+ opts.onTextDelta(P);
254
+ opts.onSteerMessage('steer one'); // seals P as its own item
255
+ opts.onTextDelta(TAIL2);
256
+ opts.onSteerMessage('steer two'); // seals TAIL2 as its own item
257
+ return { result: { content: finalContent }, session: { messages: [] } };
258
+ };
259
+ }
260
+
261
+ test('per-segment strip: final content = TAIL only (P omitted) → no new item', async () => {
262
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(TAIL2));
263
+ await createRunTurn(bag)('do a thing');
264
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
265
+ assert.deepEqual(assistants.map((it) => it.text), [P, TAIL2], 'only the two committed segments exist');
266
+ });
267
+
268
+ test('per-segment strip: final content = P + newline + TAIL → no new item', async () => {
269
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(`${P}\n${TAIL2}`));
270
+ await createRunTurn(bag)('do a thing');
271
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
272
+ assert.deepEqual(assistants.map((it) => it.text), [P, TAIL2], 'both segments stripped; nothing re-emitted');
273
+ });
274
+
275
+ test('per-segment strip: final content extends past both committed segments → only remainder', async () => {
276
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(`${P}\n${TAIL2}\nmore`));
277
+ await createRunTurn(bag)('do a thing');
278
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
279
+ assert.equal(assistants.length, 3, 'committed P + TAIL + the new remainder');
280
+ assert.equal(assistants[2].text, '\nmore', 'only the uncommitted remainder is appended');
281
+ assert.equal(assistants[2].streaming, false);
282
+ });
283
+
284
+ test('per-segment strip: segment sealed WITH a leading newline still peels', async () => {
285
+ // The committed segment carries its own leading '\n' ('\nTAIL'); the provider
286
+ // returns the trimmed 'TAIL'. Trimming the segment before the compare lets it
287
+ // peel so it is not duplicated after the steering row.
288
+ const LEAD_TAIL = '\nterminal tail sealed with a leading newline';
289
+ const ask = async (_userText, opts) => {
290
+ opts.onTextDelta(LEAD_TAIL);
291
+ opts.onSteerMessage('steer now');
292
+ return { result: { content: LEAD_TAIL.replace(/^\s+/, '') }, session: { messages: [] } };
293
+ };
294
+ const { bag, getState } = makeTurnBag(ask);
295
+ await createRunTurn(bag)('do a thing');
296
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
297
+ assert.equal(assistants.length, 1, 'leading-newline segment must not be duplicated at finalize');
298
+ assert.equal(assistants[0].text, LEAD_TAIL);
299
+ });
@@ -609,6 +609,19 @@ const graphOut = await executeCodeGraphTool('code_graph', {
609
609
  file: 'scripts/smoke.mjs',
610
610
  }, root);
611
611
  assertOk('code_graph', graphOut, /binding|spawnSync|symbol/i);
612
+ const graphStringSymbolOut = await executeCodeGraphTool('code_graph', {
613
+ mode: 'symbols',
614
+ symbols: 'executeBuiltinTool',
615
+ }, root);
616
+ assertOk('code_graph string symbols', graphStringSymbolOut, /executeBuiltinTool|symbol_search/i);
617
+ const graphRootAnchorOut = await executeCodeGraphTool('code_graph', {
618
+ mode: 'symbol_search',
619
+ symbol: 'executeBuiltinTool',
620
+ file: root,
621
+ }, root);
622
+ if (/file not found|outside cwd|arbitrary tree/i.test(String(graphRootAnchorOut))) {
623
+ throw new Error(`code_graph redundant root anchor was not normalized:\n${graphRootAnchorOut}`);
624
+ }
612
625
 
613
626
  const graphSymbolBatchOut = await executeCodeGraphTool('code_graph', {
614
627
  mode: 'symbol_search',
@@ -1242,16 +1255,11 @@ if (normalizedExplore.length !== 2 || normalizedExplore[0] !== 'where is model s
1242
1255
  }
1243
1256
  if (MAX_FANOUT_QUERIES !== 8) throw new Error(`explore fanout cap changed: ${MAX_FANOUT_QUERIES}`);
1244
1257
  const explorerPrompt = buildExplorerPrompt('where is <agent> & status?');
1245
- if (!explorerPrompt.includes('&lt;agent&gt;') || !explorerPrompt.includes('&amp;') || /verdicts, ratings, or recommendations/.test(explorerPrompt) === false) {
1258
+ if (explorerPrompt !== '<query>where is &lt;agent&gt; &amp; status?</query>') {
1246
1259
  throw new Error(`explorer prompt contract failed: ${explorerPrompt}`);
1247
1260
  }
1248
- if (
1249
- !/STOP and answer NOW/.test(explorerPrompt)
1250
- || !/Turns 2-3 exist SOLELY as zero-hit recovery/.test(explorerPrompt)
1251
- || !/HARD max 3 tool turns/.test(explorerPrompt)
1252
- || !/turn 1\/3/.test(explorerPrompt)
1253
- ) {
1254
- throw new Error(`explorer prompt must force immediate answer on a specific-token anchor while preserving the 3-turn cap: ${explorerPrompt}`);
1261
+ if (/Reminder:|BUDGET|STOP and answer|verdicts|ratings|recommendations|grep|code_graph|find|glob/i.test(explorerPrompt)) {
1262
+ throw new Error(`explorer prompt must not duplicate the system routing/fan-out contract: ${explorerPrompt}`);
1255
1263
  }
1256
1264
  setInternalToolsProvider({
1257
1265
  executor: async () => 'tool-smoke internal tool',
@@ -1358,7 +1366,7 @@ setInternalToolsProvider({
1358
1366
  if (!/Read-only retrieval role/i.test(visible) || /# environment/i.test(visible) || /git operations deferred to Lead/i.test(visible)) {
1359
1367
  throw new Error(`explorer hidden retrieval context should stay slim: ${visible.slice(0, 1200)}`);
1360
1368
  }
1361
- if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/Coordinate locator/i.test(systemVisible)) {
1369
+ if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/deliver WHERE/i.test(systemVisible)) {
1362
1370
  throw new Error(`explorer role md must ride BP2 system, not BP3 user reminder: system=${systemVisible.slice(0, 600)} user=${userReminderVisible.slice(0, 600)}`);
1363
1371
  }
1364
1372
  // System layers (BP1 tool policy + BP2 role md) are shared/frozen and sized
@@ -1734,7 +1742,7 @@ const readArrayItemAnyOf = readArraySchema?.items?.anyOf || [];
1734
1742
  if (!readArrayItemAnyOf.some((entry) => entry?.type === 'object' && entry?.properties?.offset && entry?.properties?.limit)) {
1735
1743
  throw new Error('read schema must expose array-of-region objects for batched spans');
1736
1744
  }
1737
- if (/line\+context/i.test(readDescription) || !/verified file path/i.test(readDescription) || !/Unknown path.*find first/i.test(readDescription) || !/Batch paths\/regions as real arrays/i.test(readDescription)) {
1745
+ if (/line\+context/i.test(readDescription) || !/verified file path/i.test(readDescription) || !/guessed path.*find first/i.test(readDescription) || !/Batch paths\/regions as real arrays/i.test(readDescription)) {
1738
1746
  throw new Error('read description must expose offset/limit as the single window form');
1739
1747
  }
1740
1748
  if (readProps.line || readProps.context) {
@@ -1834,7 +1842,7 @@ if (codeGraphSymbolSearchErr) {
1834
1842
  if (!/code structure\/flow/i.test(codeGraphDescription) || !/symbols\/references\/calls\/deps/i.test(codeGraphDescription)) {
1835
1843
  throw new Error('code_graph description must stay structure-oriented and name its symbol modes');
1836
1844
  }
1837
- if (!/Known symbols or verified files only/i.test(codeGraphDescription) || !/Batch symbols\[\]\/files\[\]/i.test(codeGraphDescription)) {
1845
+ if (!/files\[\] must be verified paths/i.test(codeGraphDescription) || !/Batch symbols\[\]\/files\[\]/i.test(codeGraphDescription)) {
1838
1846
  throw new Error('code_graph description must route unknown file paths through locators first');
1839
1847
  }
1840
1848
  if (!/repo-local/i.test(codeGraphDescription) || !/NOT web search|not web/i.test(codeGraphDescription)) {
@@ -2150,7 +2158,7 @@ const grepHeadLimitDescription = grepTool?.inputSchema?.properties?.head_limit?.
2150
2158
  if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file\/dir/i.test(grepPathDescription)) {
2151
2159
  throw new Error('grep schema must keep compact pattern/path guidance');
2152
2160
  }
2153
- if (!/verified scope/i.test(grepTool?.description || '') || !/Unknown path\/name.*find first/i.test(grepTool?.description || '')) {
2161
+ if (!/verified scope/i.test(grepTool?.description || '') || !/guessed path fragment.*find first/i.test(grepTool?.description || '')) {
2154
2162
  throw new Error('grep description must require verified scopes and locator-first unknown paths');
2155
2163
  }
2156
2164
  if (!/Glob filter/i.test(grepGlobDescription) || !/no guessed src\/\*\*/i.test(grepGlobDescription)) {
@@ -2174,7 +2182,7 @@ if (!/exact glob from verified roots/i.test(globTool?.description || '')) {
2174
2182
  if (!/Partial path\/name lookup/i.test(findTool?.description || '') || !/verify roots before grep\/glob/i.test(findTool?.description || '')) {
2175
2183
  throw new Error('find description must advertise unverified path/name lookup and verified outputs');
2176
2184
  }
2177
- if (!/List verified directories/i.test(listTool?.description || '') || !/Unknown dir.*find first/i.test(listTool?.description || '') || !/Verified directory/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
2185
+ if (!/List verified directories/i.test(listTool?.description || '') || !/Guessed dir.*find first/i.test(listTool?.description || '') || !/Verified directory/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
2178
2186
  throw new Error('list description must require verified directories and locator-first unknown dirs');
2179
2187
  }
2180
2188
  if (!/Repo-local/i.test(codeGraphProps.mode?.description || '') || !/one call/i.test(codeGraphProps.symbols?.description || '')) {
@@ -0,0 +1,202 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ aggregateToolCategoryEntry,
6
+ formatAggregateHeader,
7
+ summarizeToolArgs,
8
+ toolWorkUnit,
9
+ } from '../src/runtime/shared/tool-surface.mjs';
10
+ import {
11
+ normalizeToolTerminalStatus,
12
+ toolResultTerminalStatus,
13
+ } from '../src/runtime/shared/tool-status.mjs';
14
+ import {
15
+ aggregateBucketForCategory,
16
+ toolCallOutcome,
17
+ } from '../src/tui/engine/tool-result-status.mjs';
18
+ import { createToolCardResults } from '../src/tui/engine/tool-card-results.mjs';
19
+ import {
20
+ appendAgentResponseTail,
21
+ formatAgentResponseRaw,
22
+ } from '../src/tui/engine/agent-response-tail.mjs';
23
+
24
+ test('tool action copy keeps Add/Delete patch verbs and human read offsets', () => {
25
+ assert.equal(
26
+ toolWorkUnit('apply_patch', { patch: '*** Begin Patch\n*** Add File: new.mjs\n+x\n*** End Patch' }).done,
27
+ 'Created',
28
+ );
29
+ assert.equal(
30
+ toolWorkUnit('apply_patch', { patch: '*** Begin Patch\n*** Delete File: old.mjs\n*** End Patch' }).active,
31
+ 'Deleting',
32
+ );
33
+ assert.match(summarizeToolArgs('read', { path: 'a.mjs', offset: 0, limit: 10 }), /lines 1-10/);
34
+ });
35
+
36
+ test('Agent aggregation is batch-scoped and keeps category action wording', () => {
37
+ const entry = aggregateToolCategoryEntry('agent', { type: 'spawn' }, 'Agent');
38
+ assert.equal(formatAggregateHeader({ [entry.key]: { ...entry, count: 2 } }, { pending: false }), 'Called 2 agents');
39
+ assert.notEqual(
40
+ aggregateBucketForCategory('Agent', { agentBatch: 1 }),
41
+ aggregateBucketForCategory('Agent', { agentBatch: 2 }),
42
+ );
43
+ });
44
+
45
+ test('terminal result statuses stay separate from tool-call failure accounting', () => {
46
+ assert.equal(normalizeToolTerminalStatus('denied'), 'denied');
47
+ assert.equal(toolResultTerminalStatus('[status: cancelled]\n'), 'cancelled');
48
+
49
+ const state = {
50
+ items: [{
51
+ id: 'aggregate',
52
+ kind: 'tool',
53
+ result: null,
54
+ count: 1,
55
+ completedCount: 0,
56
+ }],
57
+ };
58
+ const aggregate = {
59
+ itemId: 'aggregate',
60
+ calls: new Map([['call_1', { name: 'web_fetch', args: {}, category: 'Web Research' }]]),
61
+ nextSummarySeq: 0,
62
+ ensureVisible: () => {},
63
+ };
64
+ const { patchToolCardResult } = createToolCardResults({
65
+ getState: () => state,
66
+ set: () => {},
67
+ patchItem: (id, patch) => {
68
+ const index = state.items.findIndex((item) => item.id === id);
69
+ state.items[index] = { ...state.items[index], ...patch };
70
+ return true;
71
+ },
72
+ markToolCallDone: () => {},
73
+ updateAgentJobCard: () => {},
74
+ buildAgentJobCardPatch: () => ({}),
75
+ agentStatusState: () => ({}),
76
+ });
77
+ const card = { itemId: 'aggregate', callId: 'call_1', done: false, aggregate };
78
+ assert.equal(patchToolCardResult(card, {
79
+ tool_call_id: 'call_1',
80
+ content: 'status: failed\nHTTP 404 Not Found',
81
+ }, new Map(), new Set()), true);
82
+ assert.equal(state.items[0].errorCount, 0);
83
+ assert.equal(state.items[0].callErrorCount, 0);
84
+ assert.equal(aggregate.calls.get('call_1').isError, false);
85
+ });
86
+
87
+ function patchAggregate(messages) {
88
+ const state = { items: [{ id: 'aggregate', kind: 'tool', result: null, count: messages.length, completedCount: 0 }] };
89
+ const aggregate = {
90
+ itemId: 'aggregate',
91
+ calls: new Map(messages.map((message) => [message.tool_call_id, {
92
+ name: 'shell',
93
+ args: {},
94
+ category: 'Shell',
95
+ }])),
96
+ nextSummarySeq: 0,
97
+ ensureVisible: () => {},
98
+ };
99
+ const { patchToolCardResult } = createToolCardResults({
100
+ getState: () => state,
101
+ set: () => {},
102
+ patchItem: (id, patch) => {
103
+ const index = state.items.findIndex((item) => item.id === id);
104
+ state.items[index] = { ...state.items[index], ...patch };
105
+ return true;
106
+ },
107
+ markToolCallDone: () => {},
108
+ updateAgentJobCard: () => {},
109
+ buildAgentJobCardPatch: () => ({}),
110
+ agentStatusState: () => ({}),
111
+ });
112
+ for (const message of messages) {
113
+ patchToolCardResult(
114
+ { itemId: 'aggregate', callId: message.tool_call_id, done: false, aggregate },
115
+ message,
116
+ new Map(),
117
+ new Set(),
118
+ );
119
+ }
120
+ return state.items[0];
121
+ }
122
+
123
+ test('exit detail stays successful and does not inflate Ok or Failed counts', () => {
124
+ const exit = 'Error: [shell-run-failed] [exit code: 1]\ncommand output';
125
+ const exitOnly = patchAggregate([{ tool_call_id: 'exit', content: exit, toolKind: 'error' }]);
126
+ assert.equal(exitOnly.errorCount, 0);
127
+ assert.equal(exitOnly.callErrorCount, 0);
128
+ assert.equal(exitOnly.exitErrorCount, 1);
129
+ assert.equal(exitOnly.result, 'Exit 1');
130
+
131
+ const mixed = patchAggregate([
132
+ { tool_call_id: 'failed', content: 'transport unavailable', isError: true },
133
+ { tool_call_id: 'exit', content: exit, isError: true },
134
+ ]);
135
+ assert.equal(mixed.errorCount, 1);
136
+ assert.equal(mixed.callErrorCount, 1);
137
+ assert.equal(mixed.exitErrorCount, 1);
138
+ assert.equal(mixed.result, '1 Failed · 1 Exit');
139
+ });
140
+
141
+ test('eager call outcome uses only envelope failure metadata', () => {
142
+ const exit = 'Error: [shell-run-failed] [exit code: 1]';
143
+ assert.equal(toolCallOutcome({ content: 'Error: domain failure' }, 'Error: domain failure').isCallError, false);
144
+ assert.equal(toolCallOutcome({ isError: true, content: 'ok-looking body' }, 'ok-looking body').isCallError, true);
145
+ assert.deepEqual(toolCallOutcome({ toolKind: 'error' }, exit), { isCallError: false, isExitError: true, exitCode: 1 });
146
+ assert.deepEqual(toolCallOutcome({ isError: true }, exit), { isCallError: false, isExitError: true, exitCode: 1 });
147
+ assert.equal(toolCallOutcome({ isError: true }, 'Error: [shell-run-failed] [timeout: 1s]').isCallError, true);
148
+ });
149
+
150
+ test('tail response aggregation is ordered, idempotent, and boundary-safe', () => {
151
+ const first = {
152
+ kind: 'tool',
153
+ agentDirection: 'inbound',
154
+ agentResponseKey: 'one',
155
+ agentResponseHasBody: true,
156
+ agentResponseEntries: [{ key: 'one', raw: 'body one', result: 'one', hasBody: true, isError: false }],
157
+ };
158
+ const secondPatch = appendAgentResponseTail(first, {
159
+ key: 'two', args: { type: 'result' }, rawResult: 'body two', result: 'two', hasBody: true,
160
+ }, 1);
161
+ const second = { ...first, ...secondPatch };
162
+ const thirdPatch = appendAgentResponseTail(second, {
163
+ key: 'three', args: { type: 'result' }, rawResult: 'body three', result: 'three', hasBody: true,
164
+ }, 2);
165
+ assert.equal(thirdPatch.count, 3);
166
+ assert.equal(
167
+ thirdPatch.rawResult,
168
+ '1. agent\nbody one\n\n2. agent\nbody two\n\n3. agent\nbody three',
169
+ );
170
+ const retryPatch = appendAgentResponseTail({ ...second, ...thirdPatch }, {
171
+ key: 'two', args: { type: 'result' }, rawResult: 'body two retry', result: 'two retry', hasBody: true,
172
+ }, 3);
173
+ assert.equal(retryPatch.count, 3);
174
+ assert.match(retryPatch.rawResult, /2\. agent\nbody two retry/);
175
+ assert.equal(appendAgentResponseTail({ kind: 'user' }, {
176
+ key: 'four', rawResult: 'body four', result: 'four', hasBody: true,
177
+ }), null);
178
+ assert.equal(formatAgentResponseRaw(retryPatch.agentResponseEntries).match(/\d+\. agent/g).length, 3);
179
+ });
180
+
181
+ test('failure preview upgrades its tail entry, but a boundary forces a new body card', () => {
182
+ const failurePreview = {
183
+ kind: 'tool',
184
+ agentDirection: 'inbound',
185
+ agentResponseKey: 'task_1',
186
+ agentResponseHasBody: false,
187
+ agentResponseEntries: [{
188
+ key: 'task_1', raw: 'failed preview', result: 'failed', hasBody: false, isError: true,
189
+ }],
190
+ };
191
+ const upgrade = appendAgentResponseTail(failurePreview, {
192
+ key: 'task_1', args: { type: 'result' }, rawResult: 'final body', result: 'final', hasBody: true, isError: false,
193
+ }, 1);
194
+ assert.equal(upgrade.count, 1);
195
+ assert.equal(upgrade.rawResult, '1. agent\nfinal body');
196
+ assert.equal(upgrade.isError, false);
197
+
198
+ const boundary = appendAgentResponseTail({ kind: 'assistant', text: 'intervening text' }, {
199
+ key: 'task_1', args: { type: 'result' }, rawResult: 'final body', result: 'final', hasBody: true,
200
+ });
201
+ assert.equal(boundary, null);
202
+ });
@@ -3,14 +3,17 @@ permission: read-write
3
3
  ---
4
4
 
5
5
  # Heavy Worker
6
- Broad implementation agent.
6
+ Own the assigned implementation slice through staged delivery.
7
7
 
8
- Bounded slices; smallest coherent change, not rewrite. Stop when scope is
9
- unclear or the blast radius grows.
8
+ Break work into bounded, dependency-aware slices and execute them in sequence.
9
+ At each checkpoint, run the narrowest relevant test or build before expanding
10
+ the slice. Keep the smallest coherent change; control blast radius rather than
11
+ rewriting adjacent systems.
10
12
 
11
- EDIT-FIRST DISCIPLINE. Patch the first bounded piece edit incrementally,
12
- don't read exhaustively. Repeated read-only turns without an edit = stalling;
13
- on a runtime reminder, patch the piece you understand or report blocked.
13
+ EDIT-FIRST DISCIPLINE. Patch incrementally and stop at the first explicit
14
+ boundary: unclear ownership, a missing dependency, or growing blast radius.
15
+ Do not cross that boundary without a new bounded assignment; report blocked
16
+ work with the relevant file:line.
14
17
 
15
- Self-verify edits with shell (targeted test/build).
18
+ Self-verify each checkpoint and the final slice with shell (targeted test/build).
16
19
 
@@ -4,8 +4,10 @@ permission: read
4
4
 
5
5
  # Reviewer
6
6
 
7
- Regression/risk review agent.
7
+ Independent regression/risk review agent.
8
8
 
9
- Find actionable correctness/regression/security/verification risks. Findings
10
- first, severity-ordered, one line with `file:line`; skip non-risky nits. If
11
- clean, one line + only material residual risk.
9
+ Review the approved intent, diff, and tests with independent judgment. Prioritize
10
+ actionable correctness, regression, security, and verification risks; inspect
11
+ affected boundaries. Do not reimplement the change or report non-risky nits.
12
+ Report findings first, severity-ordered, with one line per `file:line`. If clean,
13
+ say so in one line and include only material residual risk.
@@ -5,11 +5,13 @@ permission: read-write
5
5
  # Worker
6
6
  Scoped implementation agent.
7
7
 
8
- Smallest scoped change; no drive-by cleanup. Stop when done/blocked.
8
+ Own only the bounded responsibility assigned in the brief. Trust its
9
+ `file:line` anchors; do only minimal targeted discovery, then make the
10
+ smallest coherent patch. No drive-by cleanup or scope expansion.
9
11
 
10
- EDIT-FIRST DISCIPLINE. Brief anchors (`file:line`) are pre-verified — trust
11
- and patch. Repeated read-only turns without an edit = stalling; on a runtime
12
- reminder, patch now or return blocked. Threshold is "plausible", not "proven".
12
+ EDIT-FIRST DISCIPLINE. Patch promptly rather than repeating read-only turns;
13
+ stop and report blocked when the assigned scope cannot be completed.
13
14
 
14
- Self-verify edits with shell (node --check/test).
15
+ Self-verify with a targeted check (for example, `node --check` or a focused
16
+ test), then report the changed `file:line` and stop.
15
17
 
@@ -1,7 +1,7 @@
1
1
  # Public Agent Constraints
2
2
 
3
- - Do not touch git/Ship; refuse any `git add`/`commit`/`push`/`stash` with
4
- `git operations deferred to Lead`.
5
- - Shell only verifies your own edits (node --check, targeted tests, build/lint);
6
- no exploration, installs, or state changes beyond the brief.
3
+ - Do not touch git/Ship. Refuse `git add`/`commit`/`push`/`stash`: `git
4
+ operations deferred to Lead`.
5
+ - Shell only verifies own edits (node --check, targeted test, build/lint): no
6
+ exploration, install, or state change beyond brief.
7
7
  - Overflow goes to a file; hand off path + fragments.
@@ -1,16 +1,13 @@
1
1
  # Agent Constraints
2
2
 
3
- - Use English for agent task communication.
4
- - One turn = one batch; any read-only call that could have ridden the prior
5
- turn is wasted.
6
- - Never preamble/progress before tools; call tools immediately. Text only in
7
- the final handoff after tool work.
8
- - Final handoff: fragments only — outcome, key `file:line`, verification
9
- command+result, material risks/blockers. Follow stricter role contracts.
10
- - Do not repeat the brief, process, search path, or any fact. Report done/
11
- missing/blocker instead of retrieving to avoid reporting.
12
- - Handoff cap ~30 lines unless `Deliver:` raises it; this is a ceiling.
13
- - Ban headings, tables unless requested, prose narration, raw logs/tool traces,
14
- speculative next-checks, restated brief, articles/politeness.
15
- - Runtime wrap-up directives override this and may require done/remaining/
16
- blocking summary.
3
+ - Agent communication: English. One turn = one batch; a read-only call that
4
+ could join the prior turn is wasted.
5
+ - Call tools immediately: no preamble/progress; text only in final handoff.
6
+ - Final handoff is fragments: outcome, key `file:line`, verification
7
+ command+result, material risk/blocker; stricter role contracts win. Don't
8
+ repeat brief/process/search path/facts; report done/missing/blocker, don't
9
+ retrieve to report.
10
+ - Cap ~30 lines unless `Deliver:` raises it. No headings/tables unless asked,
11
+ prose narration, raw logs/tool traces, speculative next-checks, restated
12
+ brief, articles/politeness.
13
+ - Runtime wrap-up overrides; it may require done/remaining/blocking summary.
@@ -1,5 +1,5 @@
1
1
  # Skip Protocol
2
2
 
3
- For inbound-event reports (`webhook-handler`, `scheduler-task`) with nothing
4
- actionable to relay (label-only, duplicate/dedup, no action needed/report),
5
- prefix the whole response with `[meta:silent]`.
3
+ For non-actionable inbound-event reports (`webhook-handler`, `scheduler-task`:
4
+ label-only, duplicate/dedup, no action needed/report), prefix the whole response
5
+ with `[meta:silent]`.