mixdog 0.9.38 → 0.9.40

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 (124) 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/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +176 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -0,0 +1,83 @@
1
+ /**
2
+ * session-orphan-sweep-test.mjs — proves the periodic sweep reclaims mature
3
+ * closed tombstones that exist ON DISK but are ABSENT from the summary index.
4
+ *
5
+ * Regression guard for the pre-fix bug where sweepStaleSessions iterated only
6
+ * listStoredSessionSummaries() rows, so on-disk session files missing from a
7
+ * stale index were never visited and their tombstones accumulated forever.
8
+ */
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+
15
+ test('mature closed on-disk orphan absent from summary index gets tombstone-swept', async () => {
16
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-orphan-sweep-'));
17
+ process.env.MIXDOG_DATA_DIR = dataDir;
18
+ try {
19
+ const store = await import('../src/runtime/agent/orchestrator/session/store.mjs');
20
+ const { sweepStaleSessions, _sessionSummary, _writeSummaryIndex } = store;
21
+
22
+ const sessionsDir = join(dataDir, 'sessions');
23
+ mkdirSync(sessionsDir, { recursive: true });
24
+
25
+ const now = Date.now();
26
+ const TWO_HOURS = 2 * 60 * 60 * 1000;
27
+ const ONE_HOUR = 60 * 60 * 1000;
28
+
29
+ // (1) An INDEXED, fresh, open agent session — present in the summary
30
+ // index so the index is non-empty (blocking any rebuild) and must
31
+ // survive the sweep.
32
+ const indexed = {
33
+ id: 'sess_indexed_keep',
34
+ closed: false,
35
+ status: 'idle',
36
+ owner: 'agent:test',
37
+ updatedAt: now,
38
+ createdAt: now,
39
+ lastHeartbeatAt: now,
40
+ messages: [],
41
+ };
42
+ writeFileSync(join(sessionsDir, `${indexed.id}.json`), JSON.stringify(indexed));
43
+
44
+ // (2) An ORPHAN closed+mature tombstone — on disk, NOT in the index,
45
+ // closed >1h ago. Only reachable if the sweep reconciles the index
46
+ // candidate set with a direct directory scan.
47
+ const orphan = {
48
+ id: 'sess_orphan_tombstone',
49
+ closed: true,
50
+ status: 'closed',
51
+ owner: 'agent:test',
52
+ updatedAt: now - TWO_HOURS,
53
+ createdAt: now - TWO_HOURS,
54
+ messages: [],
55
+ };
56
+ writeFileSync(join(sessionsDir, `${orphan.id}.json`), JSON.stringify(orphan));
57
+
58
+ // Index contains ONLY the indexed session — the orphan is absent.
59
+ const rows = _writeSummaryIndex([_sessionSummary(indexed)]);
60
+ assert.equal(rows.length, 1, 'index seeded with exactly one row');
61
+ assert.ok(!rows.some((r) => r.id === orphan.id), 'orphan is absent from the summary index');
62
+
63
+ // Tombstone-only pass (no idle sweep) with a 1h maturity threshold.
64
+ const result = sweepStaleSessions({ sweepIdle: false, tombstoneMaxAgeMs: ONE_HOUR });
65
+
66
+ assert.equal(result.tombstonesCleaned, 1, 'the orphan tombstone was reclaimed');
67
+ assert.ok(
68
+ result.tombstoneDetails.some((d) => d.id === orphan.id),
69
+ 'sweep reports the orphan id among tombstone deletions',
70
+ );
71
+ assert.ok(
72
+ !existsSync(join(sessionsDir, `${orphan.id}.json`)),
73
+ 'orphan session file was unlinked from disk',
74
+ );
75
+ assert.ok(
76
+ existsSync(join(sessionsDir, `${indexed.id}.json`)),
77
+ 'fresh open indexed session was preserved',
78
+ );
79
+ } finally {
80
+ try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
81
+ delete process.env.MIXDOG_DATA_DIR;
82
+ }
83
+ });
@@ -1,6 +1,9 @@
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';
5
+
6
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
7
 
5
8
  // Minimal bag: drainPendingSteering only touches pending, the queue helpers,
6
9
  // and commitSteeringQueueEntries (which no-ops on disk when runtime.id is not
@@ -70,7 +73,7 @@ test('drainPendingSteering accepts runtime callback signature', () => {
70
73
  assert.equal(bag.pending.length, 0);
71
74
  });
72
75
 
73
- test('drained notification key remains deduped and displayed key is not cleared', () => {
76
+ test('drained notification key is released while displayed key is not cleared', () => {
74
77
  const { flow, bag } = makeFlow();
75
78
  bag.getState().busy = true;
76
79
  assert.equal(flow.enqueue('task finished', { mode: 'task-notification', key: 'task-1' }), true);
@@ -79,7 +82,7 @@ test('drained notification key remains deduped and displayed key is not cleared'
79
82
  const out = flow.drainPendingSteering({ maxPriority: 'later' });
80
83
 
81
84
  assert.deepEqual(out, ['task finished']);
82
- assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), false);
85
+ assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), true);
83
86
  assert.equal(bag.displayedExecutionNotificationKeys.has('task-1'), true);
84
87
  });
85
88
 
@@ -118,3 +121,314 @@ test('post-turn drain does not send queued slash command to model', async () =>
118
121
  assert.equal(bag.pending.length, 1, 'slash command remains for command dispatcher');
119
122
  assert.equal(bag.pending[0].content, '/clear');
120
123
  });
124
+
125
+ // Minimal store bag for createRunTurn: only the surface the streaming/steering
126
+ // finalize path touches. runtime.ask is a caller-supplied mock that drives the
127
+ // text-delta / steer-message callbacks.
128
+ function makeTurnBag(ask, {
129
+ timeoutMs = 300000,
130
+ getTurnLiveness,
131
+ abort,
132
+ } = {}) {
133
+ let seq = 0;
134
+ const state = {
135
+ items: [],
136
+ stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
137
+ busy: false,
138
+ spinner: null,
139
+ thinking: null,
140
+ };
141
+ const itemIndexById = new Map();
142
+ const findIndexById = (id) => state.items.findIndex((it) => it.id === id);
143
+ const runtime = { id: null, toolMode: 'auto', ask, abort: abort || (() => {}) };
144
+ if (typeof getTurnLiveness === 'function') runtime.getTurnLiveness = getTurnLiveness;
145
+ const bag = {
146
+ runtime,
147
+ nextId: () => `id_${++seq}`,
148
+ tuiDebug: () => {},
149
+ LEAD_TURN_TIMEOUT_MS: timeoutMs,
150
+ flags: { leadTurnEpoch: 0 },
151
+ pending: [],
152
+ itemIndexById,
153
+ getState: () => state,
154
+ set: (patch) => Object.assign(state, patch),
155
+ pushItem: (spec) => {
156
+ state.items = [...state.items, spec];
157
+ if (spec?.id != null) itemIndexById.set(spec.id, state.items.length - 1);
158
+ },
159
+ patchItem: (id, patch) => {
160
+ const idx = findIndexById(id);
161
+ if (idx < 0) return;
162
+ const items = state.items.slice();
163
+ items[idx] = { ...items[idx], ...patch };
164
+ state.items = items;
165
+ },
166
+ pushNotice: () => {},
167
+ pushUserOrSyntheticItem: (text) => {
168
+ state.items = [...state.items, { kind: 'user', id: `u_${++seq}`, text }];
169
+ },
170
+ markToolCallActive: () => {},
171
+ markToolCallDone: () => {},
172
+ clearActiveToolSummary: () => {},
173
+ agentStatusState: () => ({}),
174
+ routeState: () => ({}),
175
+ syncContextStats: () => {},
176
+ denyAllToolApprovals: () => {},
177
+ requestToolApproval: async () => ({ approved: false }),
178
+ patchToolCardResult: () => {},
179
+ flushToolResults: () => {},
180
+ flushDeferredExecutionPendingResumeKick: () => {},
181
+ drain: async () => {},
182
+ drainPendingSteering: () => [],
183
+ };
184
+ return { bag, getState: () => state };
185
+ }
186
+
187
+ test('watchdog accepts fresh runtime liveness without tripping', async () => {
188
+ let aborts = 0;
189
+ const ask = async () => {
190
+ await wait(45);
191
+ return { result: { content: '' }, session: { messages: [] } };
192
+ };
193
+ const { bag } = makeTurnBag(ask, {
194
+ timeoutMs: 15,
195
+ abort: () => { aborts += 1; },
196
+ getTurnLiveness: () => ({
197
+ stage: 'tool_running',
198
+ lastProgressAt: Date.now(),
199
+ toolStartedAt: Date.now(),
200
+ toolSelfDeadlineMs: 0,
201
+ }),
202
+ });
203
+
204
+ assert.equal(await createRunTurn(bag)('do a thing'), 'done');
205
+ assert.equal(aborts, 0, 'fresh orchestrator heartbeats defer the watchdog');
206
+ });
207
+
208
+ test('watchdog trips at the tool ceiling despite fresh liveness', async () => {
209
+ const previousMax = process.env.MIXDOG_LEAD_TOOL_MAX_MS;
210
+ const timeoutMs = 30;
211
+ const ceilingMs = 95;
212
+ process.env.MIXDOG_LEAD_TOOL_MAX_MS = String(ceilingMs);
213
+ let rejectAsk;
214
+ let heartbeatTimer = null;
215
+ let heartbeatStartTimer = null;
216
+ let aborts = 0;
217
+ let abortAt = 0;
218
+ let livenessCalls = 0;
219
+ const toolStartedAt = Date.now();
220
+ const ask = (_userText, options) => new Promise((_resolve, reject) => {
221
+ rejectAsk = reject;
222
+ // Let the first watchdog probe defer from orchestrator liveness, then keep
223
+ // the TUI's local progress fresh so the ceiling-bounded re-arm is exercised.
224
+ heartbeatStartTimer = setTimeout(() => {
225
+ heartbeatTimer = setInterval(() => options.onStreamDelta(), 5);
226
+ }, 40);
227
+ });
228
+ const { bag } = makeTurnBag(ask, {
229
+ timeoutMs,
230
+ abort: () => {
231
+ aborts += 1;
232
+ abortAt = Date.now();
233
+ clearTimeout(heartbeatStartTimer);
234
+ clearInterval(heartbeatTimer);
235
+ const error = new Error('interrupted');
236
+ error.name = 'SessionClosedError';
237
+ rejectAsk(error);
238
+ },
239
+ getTurnLiveness: () => {
240
+ livenessCalls += 1;
241
+ return {
242
+ stage: 'tool_running',
243
+ lastProgressAt: Date.now(),
244
+ toolStartedAt,
245
+ toolSelfDeadlineMs: 0,
246
+ };
247
+ },
248
+ });
249
+
250
+ try {
251
+ const result = await Promise.race([
252
+ createRunTurn(bag)('do a thing'),
253
+ wait(180).then(() => 'watchdog did not trip at the ceiling'),
254
+ ]);
255
+ assert.equal(result, 'cancelled');
256
+ assert.equal(aborts, 1);
257
+ assert.ok(livenessCalls >= 2, 'first probe deferred, then the ceiling probe rechecked liveness');
258
+ assert.ok(abortAt - toolStartedAt < ceilingMs + timeoutMs, 'ceiling trips before an unbounded watchdog interval');
259
+ } finally {
260
+ clearTimeout(heartbeatStartTimer);
261
+ clearInterval(heartbeatTimer);
262
+ if (previousMax === undefined) delete process.env.MIXDOG_LEAD_TOOL_MAX_MS;
263
+ else process.env.MIXDOG_LEAD_TOOL_MAX_MS = previousMax;
264
+ }
265
+ });
266
+
267
+ for (const [name, getTurnLiveness] of [
268
+ ['throws', () => { throw new Error('liveness unavailable'); }],
269
+ ['returns null', () => null],
270
+ ]) {
271
+ test(`watchdog ${name} probe falls back to abort`, async () => {
272
+ let rejectAsk;
273
+ let aborts = 0;
274
+ const ask = () => new Promise((_resolve, reject) => { rejectAsk = reject; });
275
+ const { bag } = makeTurnBag(ask, {
276
+ timeoutMs: 15,
277
+ getTurnLiveness,
278
+ abort: () => {
279
+ aborts += 1;
280
+ const error = new Error('interrupted');
281
+ error.name = 'SessionClosedError';
282
+ rejectAsk(error);
283
+ },
284
+ });
285
+
286
+ const [result] = await Promise.all([createRunTurn(bag)('do a thing'), wait(40)]);
287
+ assert.equal(result, 'cancelled');
288
+ assert.equal(aborts, 1);
289
+ });
290
+ }
291
+
292
+ test('stream-delta progress keeps the watchdog alive', async () => {
293
+ let aborts = 0;
294
+ let streamDeltas = 0;
295
+ const ask = async (_userText, options) => {
296
+ options.onStreamDelta();
297
+ streamDeltas += 1;
298
+ await wait(12);
299
+ options.onStreamDelta();
300
+ streamDeltas += 1;
301
+ await wait(12);
302
+ return { result: { content: '' }, session: { messages: [] } };
303
+ };
304
+ const { bag } = makeTurnBag(ask, {
305
+ timeoutMs: 20,
306
+ abort: () => { aborts += 1; },
307
+ });
308
+
309
+ assert.equal(await createRunTurn(bag)('do a thing'), 'done');
310
+ assert.equal(streamDeltas, 2);
311
+ assert.equal(aborts, 0);
312
+ });
313
+
314
+ test('onSteerMessage commits a streamed no-newline assistant tail into items', async () => {
315
+ // A terminal no-tool response streams a single line WITHOUT a trailing '\n',
316
+ // so no assistant row/currentAssistantId exists yet. A steering injection
317
+ // races finalization and must seal the pending tail instead of dropping it.
318
+ const TAIL = 'partial answer with no trailing newline';
319
+ const ask = async (_userText, opts) => {
320
+ opts.onTextDelta(TAIL);
321
+ opts.onSteerMessage('steer now');
322
+ return { result: { content: '' }, session: { messages: [] } };
323
+ };
324
+ const { bag, getState } = makeTurnBag(ask);
325
+ const runTurn = createRunTurn(bag);
326
+
327
+ await runTurn('do a thing');
328
+
329
+ const assistant = getState().items.find((it) => it.kind === 'assistant');
330
+ assert.ok(assistant, 'streamed no-newline tail must be committed as an assistant item');
331
+ assert.equal(assistant.text, TAIL);
332
+ assert.equal(assistant.streaming, false);
333
+ });
334
+
335
+ test('finalization does not duplicate a steer-committed tail when result.content repeats it', async () => {
336
+ // Same steer race, but the provider's final content equals the already-
337
+ // committed tail. Finalization must NOT re-emit it as a second item.
338
+ const TAIL = 'partial answer with no trailing newline';
339
+ const ask = async (_userText, opts) => {
340
+ opts.onTextDelta(TAIL);
341
+ opts.onSteerMessage('steer now');
342
+ return { result: { content: TAIL }, session: { messages: [] } };
343
+ };
344
+ const { bag, getState } = makeTurnBag(ask);
345
+ const runTurn = createRunTurn(bag);
346
+
347
+ await runTurn('do a thing');
348
+
349
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
350
+ assert.equal(assistants.length, 1, 'the committed tail must not be duplicated at finalize');
351
+ assert.equal(assistants[0].text, TAIL);
352
+ assert.equal(assistants[0].streaming, false);
353
+ });
354
+
355
+ test('finalization appends only the uncommitted remainder past a steer-committed tail', async () => {
356
+ // Provider content extends past the committed tail: only the new remainder
357
+ // becomes a fresh item, ordered after the committed segment (+ steering row).
358
+ const TAIL = 'partial answer with no trailing newline';
359
+ const REMAINDER = '\nmore text arriving after the steer';
360
+ const ask = async (_userText, opts) => {
361
+ opts.onTextDelta(TAIL);
362
+ opts.onSteerMessage('steer now');
363
+ return { result: { content: TAIL + REMAINDER }, session: { messages: [] } };
364
+ };
365
+ const { bag, getState } = makeTurnBag(ask);
366
+ const runTurn = createRunTurn(bag);
367
+
368
+ await runTurn('do a thing');
369
+
370
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
371
+ assert.equal(assistants.length, 2, 'committed tail + remainder are two distinct items');
372
+ assert.equal(assistants[0].text, TAIL, 'committed tail stays first, unchanged');
373
+ assert.equal(assistants[1].text, REMAINDER, 'only the uncommitted remainder is appended');
374
+ assert.equal(assistants[1].streaming, false);
375
+ const steerIdx = getState().items.findIndex((it) => it.kind === 'user' && it.text === 'steer now');
376
+ const remainderIdx = getState().items.findIndex((it) => it.kind === 'assistant' && it.text === REMAINDER);
377
+ assert.ok(steerIdx >= 0 && remainderIdx > steerIdx, 'remainder appends after the injected steering row');
378
+ });
379
+
380
+ // Two committed segments this turn: a prior preamble P, then a no-newline TAIL
381
+ // sealed by the steer race. The provider's final content may OMIT P — the
382
+ // per-segment strip must still peel BOTH out (a single concatenated 'P+TAIL'
383
+ // prefix would fail to match and duplicate TAIL after the steering row).
384
+ const P = 'preamble sealed before the tail';
385
+ const TAIL2 = 'terminal tail with no trailing newline';
386
+ function makeTwoSegmentAsk(finalContent) {
387
+ return async (_userText, opts) => {
388
+ opts.onTextDelta(P);
389
+ opts.onSteerMessage('steer one'); // seals P as its own item
390
+ opts.onTextDelta(TAIL2);
391
+ opts.onSteerMessage('steer two'); // seals TAIL2 as its own item
392
+ return { result: { content: finalContent }, session: { messages: [] } };
393
+ };
394
+ }
395
+
396
+ test('per-segment strip: final content = TAIL only (P omitted) → no new item', async () => {
397
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(TAIL2));
398
+ await createRunTurn(bag)('do a thing');
399
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
400
+ assert.deepEqual(assistants.map((it) => it.text), [P, TAIL2], 'only the two committed segments exist');
401
+ });
402
+
403
+ test('per-segment strip: final content = P + newline + TAIL → no new item', async () => {
404
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(`${P}\n${TAIL2}`));
405
+ await createRunTurn(bag)('do a thing');
406
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
407
+ assert.deepEqual(assistants.map((it) => it.text), [P, TAIL2], 'both segments stripped; nothing re-emitted');
408
+ });
409
+
410
+ test('per-segment strip: final content extends past both committed segments → only remainder', async () => {
411
+ const { bag, getState } = makeTurnBag(makeTwoSegmentAsk(`${P}\n${TAIL2}\nmore`));
412
+ await createRunTurn(bag)('do a thing');
413
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
414
+ assert.equal(assistants.length, 3, 'committed P + TAIL + the new remainder');
415
+ assert.equal(assistants[2].text, '\nmore', 'only the uncommitted remainder is appended');
416
+ assert.equal(assistants[2].streaming, false);
417
+ });
418
+
419
+ test('per-segment strip: segment sealed WITH a leading newline still peels', async () => {
420
+ // The committed segment carries its own leading '\n' ('\nTAIL'); the provider
421
+ // returns the trimmed 'TAIL'. Trimming the segment before the compare lets it
422
+ // peel so it is not duplicated after the steering row.
423
+ const LEAD_TAIL = '\nterminal tail sealed with a leading newline';
424
+ const ask = async (_userText, opts) => {
425
+ opts.onTextDelta(LEAD_TAIL);
426
+ opts.onSteerMessage('steer now');
427
+ return { result: { content: LEAD_TAIL.replace(/^\s+/, '') }, session: { messages: [] } };
428
+ };
429
+ const { bag, getState } = makeTurnBag(ask);
430
+ await createRunTurn(bag)('do a thing');
431
+ const assistants = getState().items.filter((it) => it.kind === 'assistant');
432
+ assert.equal(assistants.length, 1, 'leading-newline segment must not be duplicated at finalize');
433
+ assert.equal(assistants[0].text, LEAD_TAIL);
434
+ });
@@ -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 || '')) {