mixdog 0.9.47 → 0.9.50

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 (88) hide show
  1. package/README.md +6 -6
  2. package/package.json +19 -9
  3. package/scripts/agent-parallel-smoke.mjs +4 -1
  4. package/scripts/agent-route-batch-test.mjs +40 -0
  5. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  6. package/scripts/code-graph-description-contract.mjs +185 -0
  7. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  8. package/scripts/code-graph-dispatch-test.mjs +96 -0
  9. package/scripts/compact-pressure-test.mjs +40 -0
  10. package/scripts/deferred-tool-loading-test.mjs +233 -0
  11. package/scripts/embedding-worker-exit-test.mjs +76 -0
  12. package/scripts/execution-completion-dedup-test.mjs +48 -0
  13. package/scripts/explore-prompt-policy-test.mjs +88 -3
  14. package/scripts/live-worker-smoke.mjs +68 -16
  15. package/scripts/memory-core-input-test.mjs +33 -13
  16. package/scripts/native-edit-wire-test.mjs +152 -0
  17. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  18. package/scripts/patch-binary-cache-test.mjs +181 -0
  19. package/scripts/prompt-immediate-render-test.mjs +89 -0
  20. package/scripts/provider-toolcall-test.mjs +280 -15
  21. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  22. package/scripts/smoke-loop.mjs +9 -3
  23. package/scripts/smoke.mjs +5 -106
  24. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  25. package/scripts/streaming-tail-window-test.mjs +29 -0
  26. package/scripts/tool-failures.mjs +21 -3
  27. package/scripts/tool-smoke.mjs +263 -38
  28. package/scripts/tool-tui-presentation-test.mjs +17 -1
  29. package/scripts/tui-perf-run.ps1 +26 -0
  30. package/scripts/tui-transcript-perf-test.mjs +7 -1
  31. package/scripts/verify-release-assets-test.mjs +281 -0
  32. package/scripts/verify-release-assets.mjs +293 -0
  33. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  34. package/src/cli.mjs +1 -0
  35. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  38. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  41. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  43. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  44. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  45. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  46. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  48. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  52. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  53. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  54. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  55. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  57. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  58. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  59. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  61. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  62. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  63. package/src/runtime/memory/tool-defs.mjs +1 -2
  64. package/src/session-runtime/context-status.mjs +25 -16
  65. package/src/session-runtime/model-route-api.mjs +2 -0
  66. package/src/session-runtime/runtime-core.mjs +2 -2
  67. package/src/session-runtime/session-turn-api.mjs +4 -1
  68. package/src/session-runtime/tool-catalog.mjs +113 -19
  69. package/src/session-runtime/tool-defs.mjs +1 -0
  70. package/src/standalone/agent-tool/helpers.mjs +25 -6
  71. package/src/standalone/agent-tool.mjs +75 -41
  72. package/src/tui/App.jsx +4 -0
  73. package/src/tui/app/input-parsers.mjs +8 -9
  74. package/src/tui/components/Markdown.jsx +6 -1
  75. package/src/tui/components/Message.jsx +11 -2
  76. package/src/tui/components/PromptInput.jsx +19 -21
  77. package/src/tui/components/Spinner.jsx +4 -4
  78. package/src/tui/components/StatusLine.jsx +6 -6
  79. package/src/tui/components/TranscriptItem.jsx +2 -2
  80. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  81. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  82. package/src/tui/dist/index.mjs +130 -45
  83. package/src/tui/engine/agent-job-feed.mjs +21 -2
  84. package/src/tui/engine/turn.mjs +12 -1
  85. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  86. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  87. package/src/ui/statusline.mjs +5 -5
  88. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -396,6 +396,46 @@ test('provider baseline pressure includes only the configured extra reserve', ()
396
396
  assert.equal(resolveCompactionPressureTokens(1, policy, { messages, sessionRef: session }), 87_000);
397
397
  });
398
398
 
399
+ test('context status uses the automatic compaction pressure numerator and trigger', () => {
400
+ const session = {
401
+ id: 'status-pressure',
402
+ provider: 'openai',
403
+ model: 'm',
404
+ tools: [],
405
+ contextWindow: 100_000,
406
+ messages: [{ role: 'user', content: 'small local estimate' }],
407
+ compaction: {},
408
+ };
409
+ recordProviderContextBaseline(session, session.messages, {
410
+ inputTokens: 80_000,
411
+ outputTokens: 2_000,
412
+ });
413
+ const policy = policyFor(session);
414
+ const expectedPressure = resolveCompactionPressureTokens(
415
+ estimateMessagesTokens(session.messages),
416
+ policy,
417
+ { messages: session.messages, sessionRef: session },
418
+ );
419
+ const { contextStatus } = createContextStatus({
420
+ getSession: () => session,
421
+ getRoute: () => ({ provider: 'openai', model: 'm' }),
422
+ getCurrentCwd: () => '',
423
+ getMode: () => 'default',
424
+ });
425
+
426
+ const status = contextStatus();
427
+ assert.equal(status.usedTokens, expectedPressure);
428
+ assert.equal(status.currentEstimatedTokens, expectedPressure);
429
+ assert.equal(status.compaction.triggerTokens, policy.triggerTokens);
430
+ assert.equal(
431
+ shouldCompactForSession(estimateMessagesTokens(session.messages), policy, {
432
+ messages: session.messages,
433
+ sessionRef: session,
434
+ }),
435
+ status.usedTokens >= status.compaction.triggerTokens,
436
+ );
437
+ });
438
+
399
439
  test('provider baselines fingerprint actual sendTools and reject provider, model, tool-schema, and prefix changes', () => {
400
440
  const make = () => ({
401
441
  provider: 'openai',
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ import {
6
+ applyDeferredToolSurface,
7
+ rebuildDeferredToolSurfaceForProvider,
8
+ reconcileDeferredMcpToolCatalog,
9
+ renderToolSearch,
10
+ } from '../src/session-runtime/tool-catalog.mjs';
11
+ import { prepareDeferredToolCallThrough } from '../src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs';
12
+ import { buildRequestBody } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
13
+ import { _buildRequestBodyForCacheSmoke as buildAnthropicRequestBody } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
14
+
15
+ const schema = { type: 'object', properties: {} };
16
+ const catalog = [
17
+ { name: 'load_tool', description: 'Load deferred tools.', annotations: { readOnlyHint: true }, inputSchema: schema },
18
+ { name: 'read', description: 'Read.', annotations: { readOnlyHint: true }, inputSchema: schema },
19
+ { name: 'mcp__demo__ping', description: 'Ping.', annotations: { readOnlyHint: true }, inputSchema: schema },
20
+ ];
21
+
22
+ test('OpenAI first/repeated load uses history schemas and keeps base tools/cache key stable', () => {
23
+ const session = { id: 'deferred-openai', provider: 'openai-oauth', tools: [], messages: [] };
24
+ applyDeferredToolSurface(session, 'full', catalog, { provider: session.provider });
25
+ const baseTools = JSON.stringify(session.tools);
26
+ const firstBody = buildRequestBody(
27
+ [{ role: 'user', content: 'load ping' }],
28
+ 'gpt-5.4',
29
+ session.tools,
30
+ { sessionId: session.id, session },
31
+ );
32
+
33
+ const first = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
34
+ assert.deepEqual(first.loaded, ['mcp__demo__ping']);
35
+ assert.equal(session.tools.some((tool) => tool.name === 'mcp__demo__ping'), false);
36
+ assert.equal(JSON.stringify(session.tools), baseTools);
37
+
38
+ const history = [
39
+ { role: 'user', content: 'load ping' },
40
+ {
41
+ role: 'assistant',
42
+ content: '',
43
+ toolCalls: [{
44
+ id: 'search-1',
45
+ name: 'load_tool',
46
+ arguments: { names: ['mcp__demo__ping'] },
47
+ nativeType: 'tool_search_call',
48
+ }],
49
+ },
50
+ {
51
+ role: 'tool',
52
+ toolCallId: 'search-1',
53
+ content: first.nativeToolSearch.summary,
54
+ nativeToolSearch: first.nativeToolSearch,
55
+ },
56
+ ];
57
+ const followup = buildRequestBody(history, 'gpt-5.4', session.tools, {
58
+ sessionId: session.id,
59
+ session,
60
+ });
61
+ assert.equal(JSON.stringify(followup.tools), JSON.stringify(firstBody.tools));
62
+ assert.equal(followup.prompt_cache_key, firstBody.prompt_cache_key);
63
+ assert.equal(followup.tools.some((tool) => tool.name === 'mcp__demo__ping'), false);
64
+ assert.equal(
65
+ followup.input.find((item) => item.type === 'tool_search_output').tools[0].name,
66
+ 'mcp__demo__ping',
67
+ );
68
+
69
+ const repeated = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
70
+ assert.deepEqual(repeated.loaded, []);
71
+ assert.deepEqual(repeated.alreadyActive, ['mcp__demo__ping']);
72
+ assert.deepEqual(repeated.nativeToolSearch.toolReferences, []);
73
+ assert.deepEqual(repeated.nativeToolSearch.openaiTools, []);
74
+ assert.equal(JSON.stringify(session.tools), baseTools);
75
+
76
+ const repeatedFollowup = buildRequestBody([
77
+ ...history,
78
+ {
79
+ role: 'assistant',
80
+ content: '',
81
+ toolCalls: [{
82
+ id: 'search-2',
83
+ name: 'load_tool',
84
+ arguments: { names: ['mcp__demo__ping'] },
85
+ nativeType: 'tool_search_call',
86
+ }],
87
+ },
88
+ {
89
+ role: 'tool',
90
+ toolCallId: 'search-2',
91
+ content: repeated.nativeToolSearch.summary,
92
+ nativeToolSearch: repeated.nativeToolSearch,
93
+ },
94
+ ], 'gpt-5.4', session.tools, { sessionId: session.id, session });
95
+ const repeatedOutput = repeatedFollowup.input.at(-1);
96
+ assert.equal(JSON.stringify(repeatedFollowup.tools), JSON.stringify(firstBody.tools));
97
+ assert.equal(repeatedFollowup.prompt_cache_key, firstBody.prompt_cache_key);
98
+ assert.equal(repeatedFollowup.input.at(-2).type, 'tool_search_call');
99
+ assert.equal(repeatedOutput.type, 'tool_search_output');
100
+ assert.deepEqual(repeatedOutput.tools, []);
101
+ });
102
+
103
+ test('Anthropic repeated native loads retain an empty provider envelope', () => {
104
+ const session = { provider: 'anthropic-oauth', tools: [], messages: [] };
105
+ applyDeferredToolSurface(session, 'full', catalog, { provider: session.provider });
106
+ const first = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
107
+ const before = JSON.stringify(session.tools);
108
+ const repeated = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
109
+
110
+ assert.deepEqual(first.nativeToolSearch.toolReferences, ['mcp__demo__ping']);
111
+ assert.deepEqual(repeated.loaded, []);
112
+ assert.deepEqual(repeated.alreadyActive, ['mcp__demo__ping']);
113
+ assert.deepEqual(repeated.nativeToolSearch.toolReferences, []);
114
+ assert.deepEqual(repeated.nativeToolSearch.openaiTools, []);
115
+ assert.equal(JSON.stringify(session.tools), before);
116
+ assert.equal(session.deferredCallableTools.includes('mcp__demo__ping'), true);
117
+ assert.equal(session.deferredDiscoveredTools.includes('mcp__demo__ping'), true);
118
+
119
+ const body = buildAnthropicRequestBody([
120
+ {
121
+ role: 'assistant',
122
+ content: '',
123
+ toolCalls: [{ id: 'load-a1', name: 'load_tool', arguments: { names: ['mcp__demo__ping'] } }],
124
+ },
125
+ {
126
+ role: 'tool',
127
+ toolCallId: 'load-a1',
128
+ content: first.nativeToolSearch.summary,
129
+ nativeToolSearch: first.nativeToolSearch,
130
+ },
131
+ {
132
+ role: 'assistant',
133
+ content: '',
134
+ toolCalls: [{ id: 'load-a2', name: 'load_tool', arguments: { names: ['mcp__demo__ping'] } }],
135
+ },
136
+ {
137
+ role: 'tool',
138
+ toolCallId: 'load-a2',
139
+ content: repeated.nativeToolSearch.summary,
140
+ nativeToolSearch: repeated.nativeToolSearch,
141
+ },
142
+ ], 'claude-sonnet-4-6', session.tools, { session });
143
+ const results = body.messages.flatMap((message) => (
144
+ Array.isArray(message.content) ? message.content.filter((block) => block.type === 'tool_result') : []
145
+ ));
146
+ assert.deepEqual(results.map((block) => block.tool_use_id), ['load-a1', 'load-a2']);
147
+ assert.deepEqual(results[0].content, [{ type: 'tool_reference', tool_name: 'mcp__demo__ping' }]);
148
+ assert.equal(results[1].content, 'Already active: mcp__demo__ping');
149
+ assert.equal(body.tools.find((tool) => tool.name === 'mcp__demo__ping')?.defer_loading, true);
150
+ });
151
+
152
+ test('OpenAI API-key path accepts its own native tool_search_output history', () => {
153
+ const session = { id: 'deferred-openai-direct', provider: 'openai', tools: [], messages: [] };
154
+ applyDeferredToolSurface(session, 'full', catalog, { provider: session.provider });
155
+ const loaded = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
156
+ const body = buildRequestBody([
157
+ {
158
+ role: 'assistant',
159
+ content: '',
160
+ toolCalls: [{
161
+ id: 'search-direct',
162
+ name: 'load_tool',
163
+ arguments: { names: ['mcp__demo__ping'] },
164
+ nativeType: 'tool_search_call',
165
+ }],
166
+ },
167
+ {
168
+ role: 'tool',
169
+ toolCallId: 'search-direct',
170
+ content: loaded.nativeToolSearch.summary,
171
+ nativeToolSearch: loaded.nativeToolSearch,
172
+ },
173
+ ], 'gpt-5.4', session.tools, {
174
+ sessionId: session.id,
175
+ session,
176
+ promptCacheProvider: 'openai',
177
+ });
178
+ assert.equal(body.input[0].type, 'tool_search_call');
179
+ assert.equal(body.input[1].type, 'tool_search_output');
180
+ });
181
+
182
+ test('subsequent ordinary MCP calls use the callable registry, not session.tools promotion', () => {
183
+ const session = { provider: 'openai-oauth', toolSpec: 'full', tools: [], messages: [] };
184
+ applyDeferredToolSurface(session, 'full', catalog, { provider: session.provider });
185
+ JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
186
+ const before = JSON.stringify(session.tools);
187
+ assert.equal(prepareDeferredToolCallThrough(session, 'mcp__demo__ping', {}), null);
188
+ assert.equal(JSON.stringify(session.tools), before);
189
+ assert.equal(session.deferredCallableTools.includes('mcp__demo__ping'), true);
190
+ });
191
+
192
+ test('xAI/Grok and unsupported providers use a fixed canonical non-native surface', () => {
193
+ for (const provider of ['xai', 'grok-oauth', 'opencode-go']) {
194
+ const session = { provider, tools: [], messages: [] };
195
+ applyDeferredToolSurface(session, 'full', catalog, { provider });
196
+ const before = JSON.stringify(session.tools);
197
+ const result = JSON.parse(renderToolSearch({ names: ['mcp__demo__ping'] }, session, 'full'));
198
+ assert.equal(session.deferredNativeTools, false);
199
+ assert.equal(result.nativeToolSearch, undefined);
200
+ assert.deepEqual(result.alreadyActive, ['mcp__demo__ping']);
201
+ assert.equal(JSON.stringify(session.tools), before);
202
+ }
203
+ });
204
+
205
+ test('Gemini manifest changes only at the next user-turn reconciliation', () => {
206
+ const session = { provider: 'gemini', tools: [], messages: [] };
207
+ applyDeferredToolSurface(session, 'full', catalog.slice(0, 2), { provider: session.provider });
208
+ const duringTurn = JSON.stringify(session.tools);
209
+ const orderedBaseNames = session.tools.map((tool) => tool.name);
210
+ assert.equal(JSON.stringify(session.tools), duringTurn);
211
+
212
+ reconcileDeferredMcpToolCatalog(session, [catalog[2]]);
213
+ assert.deepEqual(session.tools.map((tool) => tool.name), [...orderedBaseNames, 'mcp__demo__ping']);
214
+ assert.equal(session.deferredProviderMode, 'manifest');
215
+ });
216
+
217
+ test('live provider switching rebuilds native and canonical surfaces in both directions', () => {
218
+ const session = { provider: 'openai-oauth', tools: [], messages: [] };
219
+ applyDeferredToolSurface(session, 'full', catalog, { provider: session.provider });
220
+ assert.equal(session.tools.some((tool) => tool.name === 'mcp__demo__ping'), false);
221
+
222
+ rebuildDeferredToolSurfaceForProvider(session, 'xai');
223
+ session.provider = 'xai';
224
+ assert.equal(session.deferredProviderMode, 'canonical');
225
+ assert.deepEqual(session.tools.map((tool) => tool.name), session.deferredCallableTools);
226
+ assert.equal(session.tools.some((tool) => tool.name === 'mcp__demo__ping'), true);
227
+
228
+ rebuildDeferredToolSurfaceForProvider(session, 'openai');
229
+ session.provider = 'openai';
230
+ assert.equal(session.deferredProviderMode, 'native');
231
+ assert.equal(session.tools.some((tool) => tool.name === 'mcp__demo__ping'), false);
232
+ assert.equal(session.deferredCallableTools.includes('mcp__demo__ping'), false);
233
+ });
@@ -0,0 +1,76 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawn } from 'node:child_process'
4
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ const providerUrl = new URL('../src/runtime/memory/lib/embedding-provider.mjs', import.meta.url)
9
+ const CHILD_DEADLINE_MS = 3_000
10
+
11
+ async function createExitWorkerFixture() {
12
+ const tempDir = await mkdtemp(join(tmpdir(), 'mixdog-embed-worker-exit-'))
13
+ const workerPath = join(tempDir, 'exit-before-reply.mjs')
14
+ await writeFile(workerPath, `
15
+ import { parentPort } from 'node:worker_threads'
16
+ parentPort.once('message', () => process.exit(0))
17
+ `)
18
+ const source = await readFile(providerUrl, 'utf8')
19
+ const instrumented = source
20
+ .replace("'./model-profile.mjs'", JSON.stringify(new URL('../src/runtime/memory/lib/model-profile.mjs', import.meta.url).href))
21
+ .replace("'./embedding-model-config.mjs'", JSON.stringify(new URL('../src/runtime/memory/lib/embedding-model-config.mjs', import.meta.url).href))
22
+ .replace(/^const WORKER_PATH = .*$/m, `const WORKER_PATH = ${JSON.stringify(workerPath)}`)
23
+ const providerModule = `data:text/javascript;base64,${Buffer.from(instrumented).toString('base64')}#${Date.now()}`
24
+ const fixturePath = join(tempDir, 'run-exit-test.mjs')
25
+ await writeFile(fixturePath, `
26
+ import { embedText } from ${JSON.stringify(providerModule)}
27
+ const started = Date.now()
28
+ const settled = await Promise.allSettled([embedText('first'), embedText('second')])
29
+ if (Date.now() - started >= 2_000) throw new Error('worker exit did not reject pending embeds promptly')
30
+ if (settled.some(({ status }) => status !== 'rejected')) throw new Error('worker exit resolved a pending embed')
31
+ for (const result of settled) {
32
+ if (result.reason?.message !== 'Worker exited with code 0') throw result.reason
33
+ }
34
+ process.stdout.write('ok')
35
+ `)
36
+ return { fixturePath, tempDir }
37
+ }
38
+
39
+ function runFixture(fixturePath) {
40
+ return new Promise((resolve, reject) => {
41
+ const child = spawn(process.execPath, [fixturePath], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
42
+ let stdout = ''
43
+ let stderr = ''
44
+ let timedOut = false
45
+ const deadline = setTimeout(() => {
46
+ timedOut = true
47
+ child.kill()
48
+ }, CHILD_DEADLINE_MS)
49
+ child.stdout.on('data', (chunk) => { stdout += chunk })
50
+ child.stderr.on('data', (chunk) => { stderr += chunk })
51
+ child.once('error', (error) => {
52
+ clearTimeout(deadline)
53
+ reject(error)
54
+ })
55
+ child.once('close', (code, signal) => {
56
+ clearTimeout(deadline)
57
+ if (timedOut) {
58
+ reject(new Error(`exit-worker fixture exceeded ${CHILD_DEADLINE_MS}ms and was killed`))
59
+ return
60
+ }
61
+ resolve({ code, signal, stdout, stderr })
62
+ })
63
+ })
64
+ }
65
+
66
+ test('code-zero worker exit before reply promptly rejects every pending embed', async () => {
67
+ const { fixturePath, tempDir } = await createExitWorkerFixture()
68
+ try {
69
+ const result = await runFixture(fixturePath)
70
+ assert.equal(result.code, 0, result.stderr)
71
+ assert.equal(result.signal, null, result.stderr)
72
+ assert.equal(result.stdout, 'ok')
73
+ } finally {
74
+ await rm(tempDir, { recursive: true, force: true })
75
+ }
76
+ })
@@ -155,3 +155,51 @@ test('bodyless failure preview does not suppress its later body', () => {
155
155
  deliver({ content: 'Async agent task task_fail completed.\n\nResult:\n> retry', meta: { type: 'background_task_result', execution_id: 'task_fail', status: 'finished' } });
156
156
  assert.equal(cardPushes(), 2, 'same-body retry remains idempotent');
157
157
  });
158
+
159
+ test('parallel agent status bursts coalesce to one status snapshot per frame', async () => {
160
+ let handler = null;
161
+ let setCalls = 0;
162
+ const statusArgs = [];
163
+ const feed = createAgentJobFeed({
164
+ runtime: { onNotification: (fn) => { handler = fn; return () => {}; } },
165
+ getState: () => ({ busy: false }),
166
+ set: () => { setCalls += 1; },
167
+ nextId: () => 'id',
168
+ getDisposed: () => false,
169
+ patchItem: () => {},
170
+ enqueue: () => true,
171
+ drain: () => Promise.resolve(),
172
+ pushUserOrSyntheticItem: () => {},
173
+ makeQueueEntry: (text, opts = {}) => ({ text, ...opts }),
174
+ getPending: () => [],
175
+ agentStatusState: (args) => {
176
+ statusArgs.push(args);
177
+ return {};
178
+ },
179
+ displayedExecutionNotificationKeys: new Set(),
180
+ pushNotice: () => {},
181
+ });
182
+ const unsubscribe = feed.subscribeRuntimeNotifications();
183
+
184
+ for (let i = 0; i < 24; i += 1) {
185
+ handler({
186
+ content: `agent task: burst_${i}\nstatus: running`,
187
+ meta: { type: 'agent_task_status', execution_id: `burst_${i}`, status: 'running' },
188
+ });
189
+ }
190
+ assert.equal(setCalls, 0, 'burst stays deferred until the coalesce boundary');
191
+ await new Promise((resolve) => setTimeout(resolve, 30));
192
+ assert.equal(setCalls, 1);
193
+ assert.equal(statusArgs.length, 1);
194
+
195
+ for (let i = 0; i < 24; i += 1) {
196
+ handler({
197
+ content: `agent task: burst_${i}\nstatus: completed`,
198
+ meta: { type: 'agent_task_result', execution_id: `burst_${i}`, status: 'completed' },
199
+ });
200
+ }
201
+ await new Promise((resolve) => setTimeout(resolve, 30));
202
+ assert.equal(setCalls, 2);
203
+ assert.deepEqual(statusArgs.at(-1), { force: true }, 'terminal burst forces one fresh snapshot');
204
+ unsubscribe();
205
+ });
@@ -6,7 +6,14 @@ import { buildExplorerPrompt } from '../src/standalone/explore-tool.mjs';
6
6
  import { EXPLORE_TOOL } from '../src/standalone/explore-tool.mjs';
7
7
  import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
8
8
  import { CODE_GRAPH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
9
- import { isEagerDispatchable } from '../src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs';
9
+ import { TOOL_SEARCH_TOOL } from '../src/session-runtime/tool-defs.mjs';
10
+ import { createEagerDispatcher } from '../src/runtime/agent/orchestrator/session/eager-dispatch.mjs';
11
+ import { crossTurnSignature } from '../src/runtime/agent/orchestrator/session/loop/completion-guards.mjs';
12
+ import {
13
+ isEagerDispatchable,
14
+ isToolCallDedupEligible,
15
+ } from '../src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs';
16
+ import { assertCodeGraphDescriptionContract } from './code-graph-description-contract.mjs';
10
17
 
11
18
  test('explore per-query prompt contains only escaped query XML', () => {
12
19
  const prompt = buildExplorerPrompt('display model usage show usage model_usage provider_usage session cache usage state');
@@ -89,9 +96,19 @@ test('explorer locator policy retains its compact behavioral contract', () => {
89
96
  });
90
97
 
91
98
  test('canonical schemas advertise safe batching without changing tool shapes', () => {
92
- const graph = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs', import.meta.url), 'utf8');
99
+ const graph = CODE_GRAPH_TOOL_DEFS[0];
93
100
  const patch = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs', import.meta.url), 'utf8');
94
- assert.match(graph, /Batch symbols\[\]\/files\[\] by mode/i);
101
+ const mode = graph?.inputSchema?.properties?.mode?.description || '';
102
+ const description = graph?.description || '';
103
+ const symbols = graph?.inputSchema?.properties?.symbols?.description || '';
104
+ assert.doesNotThrow(() => assertCodeGraphDescriptionContract({
105
+ description,
106
+ modeDescription: mode,
107
+ symbolsDescription: symbols,
108
+ }));
109
+ assert.deepEqual(graph?.inputSchema?.required, ['mode']);
110
+ assert.equal(graph?.inputSchema?.properties?.file, undefined);
111
+ assert.equal(graph?.inputSchema?.properties?.symbol, undefined);
95
112
  assert.match(patch, /one patch/i);
96
113
  assert.match(patch, /one file block per target/i);
97
114
  });
@@ -121,6 +138,74 @@ test('code graph and eager-dispatch boundaries preserve runtime shape', () => {
121
138
  assert.equal(isEagerDispatchable('shell', tools), false);
122
139
  assert.equal(isEagerDispatchable('mcp_read', tools), true);
123
140
  assert.equal(isEagerDispatchable('mcp_write', tools), false);
141
+ assert.equal(isToolCallDedupEligible('read', tools), true);
142
+ assert.equal(isToolCallDedupEligible('mcp_read', tools), true);
143
+ });
144
+
145
+ test('same-batch load_tool and legacy tool_search repeats all execute eagerly', async () => {
146
+ const tools = [...BUILTIN_TOOLS, TOOL_SEARCH_TOOL];
147
+ assert.equal(isEagerDispatchable('load_tool', tools), true);
148
+ assert.equal(isEagerDispatchable('tool_search', tools), true);
149
+ assert.equal(isToolCallDedupEligible('load_tool', tools), false);
150
+ assert.equal(isToolCallDedupEligible('tool_search', tools), false);
151
+ assert.equal(isToolCallDedupEligible('read', tools), true);
152
+
153
+ const args = { names: ['shell'] };
154
+ const calls = [
155
+ { id: 'load-1', name: 'load_tool', arguments: args },
156
+ { id: 'load-2', name: 'load_tool', arguments: args },
157
+ { id: 'legacy-1', name: 'tool_search', arguments: args },
158
+ { id: 'legacy-2', name: 'tool_search', arguments: args },
159
+ ];
160
+ const executed = [];
161
+ const crossTurnCalls = new Map([
162
+ [crossTurnSignature('load_tool', args), { count: 1, firstIteration: 1 }],
163
+ [crossTurnSignature('tool_search', args), { count: 1, firstIteration: 1 }],
164
+ ]);
165
+ const dispatcher = createEagerDispatcher({
166
+ tools,
167
+ cwd: process.cwd(),
168
+ sessionId: null,
169
+ sessionRef: {},
170
+ signal: null,
171
+ opts: {},
172
+ crossTurnCalls,
173
+ getIterations: () => 2,
174
+ getNextIteration: () => 2,
175
+ repeatFailLimit: 3,
176
+ executeToolFn: async (name) => {
177
+ executed.push(name);
178
+ return '{}';
179
+ },
180
+ });
181
+ dispatcher.startEagerRun(calls, 0, new Set());
182
+ assert.equal(dispatcher.pending.size, 4);
183
+ await Promise.all([...dispatcher.pending.values()].map((entry) => entry.promise));
184
+ assert.deepEqual(executed, ['load_tool', 'load_tool', 'tool_search', 'tool_search']);
185
+
186
+ const normalExecuted = [];
187
+ const normalDispatcher = createEagerDispatcher({
188
+ tools,
189
+ cwd: process.cwd(),
190
+ sessionId: null,
191
+ sessionRef: {},
192
+ signal: null,
193
+ opts: {},
194
+ crossTurnCalls: new Map(),
195
+ getIterations: () => 1,
196
+ getNextIteration: () => 1,
197
+ repeatFailLimit: 3,
198
+ executeToolFn: async (name) => {
199
+ normalExecuted.push(name);
200
+ return 'ok';
201
+ },
202
+ });
203
+ normalDispatcher.startEagerRun([
204
+ { id: 'read-1', name: 'read', arguments: { path: 'same.txt' } },
205
+ { id: 'read-2', name: 'read', arguments: { path: 'same.txt' } },
206
+ ], 0, new Set());
207
+ await Promise.all([...normalDispatcher.pending.values()].map((entry) => entry.promise));
208
+ assert.deepEqual(normalExecuted, ['read']);
124
209
  });
125
210
 
126
211
  test('code graph descriptions partition file and symbol targets', () => {