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
@@ -53,7 +53,9 @@ import {
53
53
  } from '../src/runtime/agent/orchestrator/providers/gemini-cache.mjs';
54
54
  import { parseSSEStream as anthropicParseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
55
55
  import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
56
+ import { _test as _anthropicApiKeyTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
56
57
  import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
58
+ import { _test as _anthropicOAuthTest } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
57
59
  import {
58
60
  EFFORT_BETA_HEADER,
59
61
  LEGACY_EFFORT_BUDGET,
@@ -66,9 +68,191 @@ import { buildAnthropicBetaHeaders } from '../src/runtime/agent/orchestrator/pro
66
68
  import { PATCH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs';
67
69
  import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
68
70
  import { buildRequestBody as buildOpenAIOAuthRequestBody } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
71
+ import { _convertMessagesToResponsesInputForTest } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
69
72
 
70
73
  // --- Helpers ---------------------------------------------------------------
71
74
 
75
+ test('native deferred history normalizes per provider without leaking OpenAI variants to xAI', () => {
76
+ const nativePayload = {
77
+ provider: 'openai-oauth',
78
+ toolReferences: ['mcp__demo__ping'],
79
+ openaiTools: [{
80
+ type: 'function',
81
+ name: 'mcp__demo__ping',
82
+ defer_loading: true,
83
+ parameters: { type: 'object', properties: {} },
84
+ }],
85
+ };
86
+ const history = [
87
+ { role: 'user', content: 'load it' },
88
+ {
89
+ role: 'assistant',
90
+ content: '',
91
+ toolCalls: [{ id: 'load-1', name: 'load_tool', arguments: { names: ['mcp__demo__ping'] }, nativeType: 'tool_search_call' }],
92
+ },
93
+ { role: 'tool', toolCallId: 'load-1', content: 'Loaded', nativeToolSearch: nativePayload },
94
+ ];
95
+ const openai = _convertMessagesToResponsesInputForTest(history);
96
+ assert.equal(openai[1].type, 'tool_search_call');
97
+ assert.equal(openai[2].type, 'tool_search_output');
98
+ assert.equal(openai[2].tools[0].name, 'mcp__demo__ping');
99
+
100
+ const xai = _toXaiResponsesInputForTest(history, {
101
+ xaiResponses: { previousResponseId: 'resp-1', seenMessageCount: 0, model: 'grok-4' },
102
+ }, { model: 'grok-4' }).input;
103
+ assert.equal(xai.some((item) => item.type === 'tool_search_call' || item.type === 'tool_search_output'), false);
104
+ assert.equal(xai[1].type, 'function_call');
105
+ assert.equal(xai[2].type, 'function_call_output');
106
+ });
107
+
108
+ test('Anthropic native deferred result retains tool_reference history and defer_loading declarations', () => {
109
+ const base = [{ name: 'load_tool', description: 'loader', inputSchema: { type: 'object', properties: {} } }];
110
+ const deferred = { name: 'mcp__demo__ping', description: 'ping', inputSchema: { type: 'object', properties: {} } };
111
+ const session = {
112
+ deferredNativeTools: true,
113
+ deferredToolCatalog: [...base, deferred],
114
+ };
115
+ const firstBody = _buildRequestBodyForCacheSmoke(
116
+ [{ role: 'user', content: 'find ping' }],
117
+ 'claude-sonnet-4-6',
118
+ base,
119
+ { session },
120
+ );
121
+ assert.equal(firstBody.tools.some((tool) => tool.name === 'mcp__demo__ping'), false);
122
+ assert.deepEqual(_anthropicApiKeyTest.deferredAnthropicTools(base, [], { session }), []);
123
+ const history = [
124
+ {
125
+ role: 'assistant',
126
+ content: '',
127
+ toolCalls: [{ id: 'load-a1', name: 'load_tool', arguments: { names: ['mcp__demo__ping'] } }],
128
+ },
129
+ {
130
+ role: 'tool',
131
+ toolCallId: 'load-a1',
132
+ content: 'Loaded',
133
+ nativeToolSearch: {
134
+ provider: 'anthropic-oauth',
135
+ toolReferences: ['mcp__demo__ping'],
136
+ openaiTools: [],
137
+ },
138
+ },
139
+ ];
140
+ const body = _buildRequestBodyForCacheSmoke(history, 'claude-sonnet-4-6', base, { session });
141
+ const result = body.messages.flatMap((message) => Array.isArray(message.content) ? message.content : [])
142
+ .find((block) => block.type === 'tool_result');
143
+ assert.deepEqual(result.content, [{ type: 'tool_reference', tool_name: 'mcp__demo__ping' }]);
144
+ assert.equal(body.tools.find((tool) => tool.name === 'mcp__demo__ping')?.defer_loading, true);
145
+ const apiKeyHistory = history.map((message) => (
146
+ message.nativeToolSearch
147
+ ? { ...message, nativeToolSearch: { ...message.nativeToolSearch, provider: 'anthropic' } }
148
+ : message
149
+ ));
150
+ const apiKeyDiscovered = _anthropicApiKeyTest.deferredAnthropicTools(base, apiKeyHistory, { session });
151
+ assert.equal(apiKeyDiscovered.find((tool) => tool.name === 'mcp__demo__ping')?.deferLoading, true);
152
+ session.deferredDiscoveredTools = ['mcp__demo__ping'];
153
+ const compacted = _buildRequestBodyForCacheSmoke(
154
+ [{ role: 'user', content: 'continue after compact' }],
155
+ 'claude-sonnet-4-6',
156
+ base,
157
+ { session },
158
+ );
159
+ assert.equal(compacted.tools.find((tool) => tool.name === 'mcp__demo__ping')?.defer_loading, true);
160
+ const apiKeyCompacted = _anthropicApiKeyTest.deferredAnthropicTools(base, [], { session });
161
+ assert.equal(apiKeyCompacted.find((tool) => tool.name === 'mcp__demo__ping')?.deferLoading, true);
162
+ });
163
+
164
+ test('Anthropic API-key and OAuth preserve root properties across compound schemas', () => {
165
+ const properties = {
166
+ pattern: { type: 'string' },
167
+ path: { type: 'string' },
168
+ };
169
+ const branches = [
170
+ { required: ['pattern'] },
171
+ { required: ['path'] },
172
+ ];
173
+ for (const compoundKey of ['oneOf', 'anyOf', 'allOf']) {
174
+ const schema = { type: 'object', properties, [compoundKey]: branches };
175
+ const expected = { type: 'object', properties };
176
+ const apiKey = _anthropicApiKeyTest.sanitizeInputSchema(schema, 'grep');
177
+ const oauth = _anthropicOAuthTest.sanitizeInputSchema(schema, 'grep');
178
+
179
+ assert.deepEqual(apiKey, expected, `API-key ${compoundKey}`);
180
+ assert.deepEqual(oauth, expected, `OAuth ${compoundKey}`);
181
+ assert.deepEqual(apiKey, oauth, `${compoundKey} parity`);
182
+ }
183
+ });
184
+
185
+ test('OpenAI native provider-tag switches keep tool_search call/output paired', () => {
186
+ const history = [
187
+ {
188
+ role: 'assistant',
189
+ content: '',
190
+ toolCalls: [{ id: 'native-pair', name: 'load_tool', arguments: {}, nativeType: 'tool_search_call' }],
191
+ },
192
+ {
193
+ role: 'tool',
194
+ toolCallId: 'native-pair',
195
+ content: 'Loaded',
196
+ nativeToolSearch: {
197
+ provider: 'openai',
198
+ toolReferences: ['read'],
199
+ openaiTools: [{ type: 'function', name: 'read', parameters: { type: 'object', properties: {} } }],
200
+ },
201
+ },
202
+ ];
203
+ const oauth = _convertMessagesToResponsesInputForTest(history, { nativeToolSearchProvider: 'openai-oauth' });
204
+ assert.deepEqual(oauth.map((item) => item.type), ['tool_search_call', 'tool_search_output']);
205
+ history[1].nativeToolSearch.provider = 'openai-oauth';
206
+ const direct = _convertMessagesToResponsesInputForTest(history, { nativeToolSearchProvider: 'openai' });
207
+ assert.deepEqual(direct.map((item) => item.type), ['tool_search_call', 'tool_search_output']);
208
+ });
209
+
210
+ test('Anthropic native provider-tag switches preserve tool_reference and loaded schema bidirectionally', () => {
211
+ const base = [{ name: 'load_tool', description: 'loader', inputSchema: { type: 'object', properties: {} } }];
212
+ const deferred = { name: 'mcp__demo__ping', description: 'ping', inputSchema: { type: 'object', properties: {} } };
213
+ const session = {
214
+ deferredNativeTools: true,
215
+ deferredToolCatalog: [...base, deferred],
216
+ };
217
+ const history = (provider) => [
218
+ {
219
+ role: 'assistant',
220
+ content: '',
221
+ toolCalls: [{ id: 'anthropic-family-pair', name: 'load_tool', arguments: { names: ['mcp__demo__ping'] } }],
222
+ },
223
+ {
224
+ role: 'tool',
225
+ toolCallId: 'anthropic-family-pair',
226
+ content: 'Loaded deferred tools: mcp__demo__ping',
227
+ nativeToolSearch: {
228
+ provider,
229
+ toolReferences: ['mcp__demo__ping'],
230
+ openaiTools: [],
231
+ },
232
+ },
233
+ ];
234
+ const apiKeyToOauth = _buildRequestBodyForCacheSmoke(
235
+ history('anthropic'),
236
+ 'claude-sonnet-4-6',
237
+ base,
238
+ { session },
239
+ );
240
+ const oauthResult = apiKeyToOauth.messages
241
+ .flatMap((message) => Array.isArray(message.content) ? message.content : [])
242
+ .find((block) => block.type === 'tool_result');
243
+ assert.deepEqual(oauthResult.content, [{ type: 'tool_reference', tool_name: 'mcp__demo__ping' }]);
244
+ assert.equal(apiKeyToOauth.tools.find((tool) => tool.name === 'mcp__demo__ping')?.defer_loading, true);
245
+
246
+ const oauthHistory = history('anthropic-oauth');
247
+ const oauthToApiKeyMessages = _toAnthropicMessagesForTest(oauthHistory);
248
+ const apiKeyResult = oauthToApiKeyMessages
249
+ .flatMap((message) => Array.isArray(message.content) ? message.content : [])
250
+ .find((block) => block.type === 'tool_result');
251
+ assert.deepEqual(apiKeyResult.content, [{ type: 'tool_reference', tool_name: 'mcp__demo__ping' }]);
252
+ const apiKeyTools = _anthropicApiKeyTest.deferredAnthropicTools(base, oauthHistory, { session });
253
+ assert.equal(apiKeyTools.find((tool) => tool.name === 'mcp__demo__ping')?.deferLoading, true);
254
+ });
255
+
72
256
  // Wraps an array of Anthropic SSE event objects in a minimal Response-like
73
257
  // shape exposing the single `body.getReader()` API that parseSSEStream uses.
74
258
  // Each event becomes a `data: <json>` SSE frame, preceded by its `event:` line.
@@ -500,7 +684,7 @@ test('openai-compat/xai Responses: first Grok request after provider switch drop
500
684
  assert.deepEqual(input.map((item) => item.role), ['system', 'user', 'user']);
501
685
  });
502
686
 
503
- test('openai-oauth Responses: load_tool schema and history stay function_call', () => {
687
+ test('openai-oauth Responses: load_tool uses native tool_search history', () => {
504
688
  const loadTool = {
505
689
  name: 'load_tool',
506
690
  description: 'load tools',
@@ -517,19 +701,51 @@ test('openai-oauth Responses: load_tool schema and history stay function_call',
517
701
  content: '',
518
702
  toolCalls: [{ id: 'call_load_1', name: 'tool_search', arguments: { names: ['read'] }, nativeType: 'tool_search_call' }],
519
703
  },
520
- { role: 'tool', toolCallId: 'call_load_1', content: '{}' },
704
+ {
705
+ role: 'tool',
706
+ toolCallId: 'call_load_1',
707
+ content: 'Loaded deferred tools: read',
708
+ nativeToolSearch: {
709
+ provider: 'openai-oauth',
710
+ toolReferences: ['read'],
711
+ openaiTools: [{
712
+ type: 'function',
713
+ name: 'read',
714
+ description: 'read',
715
+ defer_loading: true,
716
+ parameters: { type: 'object', properties: {} },
717
+ }],
718
+ },
719
+ },
521
720
  ], 'gpt-5.5', [loadTool], {});
522
- assert.equal(JSON.stringify(body).includes('tool_search'), false);
523
- assert.deepEqual(body.tools?.[0], {
524
- type: 'function',
525
- name: 'load_tool',
721
+ assert.equal(JSON.stringify(body).includes('tool_search'), true);
722
+ assert.deepEqual(body.tools, [{
723
+ type: 'tool_search',
724
+ execution: 'client',
526
725
  description: loadTool.description,
527
726
  parameters: loadTool.inputSchema,
727
+ }]);
728
+ const call = body.input.find((item) => item.type === 'tool_search_call');
729
+ assert.deepEqual(call, {
730
+ type: 'tool_search_call',
731
+ call_id: 'call_load_1',
732
+ execution: 'client',
733
+ arguments: { names: ['read'] },
734
+ });
735
+ const output = body.input.find((item) => item.type === 'tool_search_output' && item.call_id === 'call_load_1');
736
+ assert.deepEqual(output, {
737
+ type: 'tool_search_output',
738
+ call_id: 'call_load_1',
739
+ status: 'completed',
740
+ execution: 'client',
741
+ tools: [{
742
+ type: 'function',
743
+ name: 'read',
744
+ description: 'read',
745
+ defer_loading: true,
746
+ parameters: { type: 'object', properties: {} },
747
+ }],
528
748
  });
529
- const call = body.input.find((item) => item.type === 'function_call' && item.name === 'load_tool');
530
- assert.equal(call.call_id, 'call_load_1');
531
- assert.deepEqual(JSON.parse(call.arguments), { names: ['read'] });
532
- assert.equal(body.input.some((item) => item.type === 'function_call_output' && item.call_id === 'call_load_1'), true);
533
749
  });
534
750
 
535
751
  test('openai-compat/xai Responses: custom_tool_call history replays as function_call', () => {
@@ -1432,7 +1648,7 @@ test('anthropic effort: sonnet-4-6 uses output_config + effort beta, not thinkin
1432
1648
  assert.ok(beta.includes(EFFORT_BETA_HEADER));
1433
1649
  });
1434
1650
 
1435
- test('anthropic-oauth: load_tool native references replay as ordinary tool_result after model switches', () => {
1651
+ test('anthropic-oauth: foreign native references normalize to ordinary tool_result after provider switches', () => {
1436
1652
  const body = _buildRequestBodyForCacheSmoke(
1437
1653
  [
1438
1654
  { role: 'user', content: 'load a tool' },
@@ -1445,7 +1661,7 @@ test('anthropic-oauth: load_tool native references replay as ordinary tool_resul
1445
1661
  role: 'tool',
1446
1662
  toolCallId: 'toolu_load_1',
1447
1663
  content: '{"loaded":["read"]}',
1448
- nativeToolSearch: { toolReferences: ['read'] },
1664
+ nativeToolSearch: { provider: 'openai-oauth', toolReferences: ['read'] },
1449
1665
  },
1450
1666
  ],
1451
1667
  'claude-opus-4-8',
@@ -1617,6 +1833,55 @@ test('openai oauth ws delta: warmup generate:false does not force request_proper
1617
1833
  }
1618
1834
  });
1619
1835
 
1836
+ test('openai oauth ws delta: native tool_search output replays with canonical fields', () => {
1837
+ const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
1838
+ try {
1839
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
1840
+ const baseTools = [{
1841
+ type: 'tool_search',
1842
+ execution: 'client',
1843
+ description: 'load tools',
1844
+ parameters: { type: 'object', properties: {} },
1845
+ }];
1846
+ const output = {
1847
+ type: 'tool_search_output',
1848
+ call_id: 'call_load_1',
1849
+ status: 'completed',
1850
+ execution: 'client',
1851
+ tools: [{ type: 'function', name: 'read', parameters: { type: 'object', properties: {} } }],
1852
+ };
1853
+ const body = {
1854
+ model: 'gpt-5.5',
1855
+ instructions: 'sys',
1856
+ tools: baseTools,
1857
+ prompt_cache_key: 'cache-key',
1858
+ input: [
1859
+ { type: 'message', role: 'user', content: 'load read' },
1860
+ { type: 'tool_search_call', call_id: 'call_load_1', execution: 'client', arguments: { names: ['read'] } },
1861
+ output,
1862
+ { type: 'message', role: 'user', content: 'use read' },
1863
+ ],
1864
+ };
1865
+ const delta = _computeDelta({
1866
+ entry: {
1867
+ lastRequestSansInput: _stableStringify(_sansInput(body)),
1868
+ lastResponseId: 'resp_prev',
1869
+ lastRequestInput: [body.input[0]],
1870
+ lastResponseItems: [body.input[1]],
1871
+ },
1872
+ body,
1873
+ });
1874
+ assert.equal(delta.mode, 'delta');
1875
+ assert.equal(delta.frame.prompt_cache_key, 'cache-key');
1876
+ assert.deepEqual(delta.frame.tools, baseTools);
1877
+ assert.equal(delta.frame.instructions, 'sys');
1878
+ assert.deepEqual(delta.frame.input, [output, body.input[3]]);
1879
+ } finally {
1880
+ if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
1881
+ else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
1882
+ }
1883
+ });
1884
+
1620
1885
  test('canonical frame: full-frame builder leads with type and preserves codex body key order', () => {
1621
1886
  const body = {
1622
1887
  model: 'gpt-5.5',
@@ -1639,7 +1904,7 @@ test('canonical frame: full-frame builder leads with type and preserves codex bo
1639
1904
  assert.equal(frame.previous_response_id, undefined);
1640
1905
  });
1641
1906
 
1642
- test('canonical frame: delta insert keeps key order, drops chained instructions, overrides input', () => {
1907
+ test('canonical frame: delta insert keeps key order and chained instructions, overrides input', () => {
1643
1908
  const body = {
1644
1909
  model: 'gpt-5.5',
1645
1910
  instructions: 'sys',
@@ -1648,8 +1913,8 @@ test('canonical frame: delta insert keeps key order, drops chained instructions,
1648
1913
  text: { verbosity: 'low' },
1649
1914
  };
1650
1915
  const delta = _buildResponseCreateFrame(body, { previousResponseId: 'resp_prev', inputOverride: [{ b: 2 }] });
1651
- assert.deepEqual(Object.keys(delta), ['type', 'model', 'previous_response_id', 'input', 'tool_choice', 'text']);
1652
- assert.equal(delta.instructions, undefined);
1916
+ assert.deepEqual(Object.keys(delta), ['type', 'model', 'instructions', 'previous_response_id', 'input', 'tool_choice', 'text']);
1917
+ assert.equal(delta.instructions, 'sys');
1653
1918
  assert.equal(delta.previous_response_id, 'resp_prev');
1654
1919
  assert.deepEqual(delta.input, [{ b: 2 }]);
1655
1920
  // Empty instructions is also dropped in delta mode (server resolves via prev id).
@@ -0,0 +1,211 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { classifyToolFailure } from '../src/runtime/agent/orchestrator/agent-trace-format.mjs';
8
+ import { ExecResult, execShellCommand } from '../src/runtime/agent/orchestrator/tools/shell-command.mjs';
9
+ import { _composeShellFailure, _shellFailureStatus } from '../src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs';
10
+
11
+ test('shell trace classification uses only the leading status marker', () => {
12
+ assert.equal(classifyToolFailure(
13
+ 'Error: [shell-run-failed] [exit code: 1]\n\ncommand timed out while parsing an aborted field',
14
+ 'shell',
15
+ ), 'command-exit');
16
+ assert.equal(classifyToolFailure(
17
+ 'Error: [shell-run-failed] [signal: SIGKILL]\n\n(no output)',
18
+ 'shell',
19
+ ), 'process/signal');
20
+ assert.equal(classifyToolFailure(
21
+ 'Error: [shell-run-failed] [timeout: 500ms signal: SIGTERM cause: timeout]',
22
+ 'shell',
23
+ ), 'timeout/abort');
24
+ assert.equal(classifyToolFailure(
25
+ 'Error: [shell-run-failed] [signal: SIGTERM cause: cancellation]',
26
+ 'shell',
27
+ ), 'timeout/abort');
28
+ assert.equal(classifyToolFailure(
29
+ 'Error: [shell-run-failed] [signal: SIGKILL cause: output-limit]',
30
+ 'shell',
31
+ ), 'runtime/failure');
32
+ assert.equal(classifyToolFailure(
33
+ '⚠️ destructive command warning\nError: [shell-run-failed] [signal: SIGKILL]',
34
+ 'shell',
35
+ ), 'process/signal');
36
+ });
37
+
38
+ test('shell failure rendering preserves actual signals and runtime kill causes', () => {
39
+ const status = (opts) => _shellFailureStatus(new ExecResult({
40
+ stdout: '', stderr: '', exitCode: null, taskId: 'test', ...opts,
41
+ }), 500).statusDetail;
42
+ assert.match(status({ signal: 'SIGKILL' }), /^\[signal: SIGKILL\]$/);
43
+ assert.match(status({ signal: 'SIGTERM', killed: true, killCause: 'cancellation' }),
44
+ /^\[signal: SIGTERM cause: cancellation\]$/);
45
+ assert.match(status({ signal: 'SIGTERM', killed: true, timedOut: true, killCause: 'timeout' }),
46
+ /^\[timeout: 500ms signal: SIGTERM cause: timeout\]/);
47
+ assert.match(status({
48
+ killed: true,
49
+ killCause: 'output-capture-error',
50
+ outputCaptureError: new Error('disk full'),
51
+ }), /^\[output capture failed cause: output-capture-error signal: SIGKILL\]$/);
52
+ assert.match(status({ signal: 'SIGKILL', killed: true, killCause: 'output-limit' }),
53
+ /^\[signal: SIGKILL cause: output-limit\]$/);
54
+ });
55
+
56
+ test('WMIC rewrite note follows the leading shell failure marker', () => {
57
+ const rendered = _composeShellFailure(
58
+ '[shell-run-failed] [exit code: 1]',
59
+ 'Error: ',
60
+ '[auto-rewrite: deprecated wmic process query -> PowerShell; timeout capped at 30000ms]',
61
+ '(no output)',
62
+ );
63
+ assert.match(rendered, /^Error: \[shell-run-failed\] \[exit code: 1\]\n\[auto-rewrite:/);
64
+ assert.equal(classifyToolFailure(rendered, 'shell'), 'command-exit');
65
+ });
66
+
67
+ async function withoutUnhandledProcessFailure(run) {
68
+ const uncaught = [];
69
+ const rejected = [];
70
+ const onUncaught = (err) => uncaught.push(err);
71
+ const onRejected = (err) => rejected.push(err);
72
+ process.on('uncaughtException', onUncaught);
73
+ process.on('unhandledRejection', onRejected);
74
+ try {
75
+ const result = await run();
76
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
77
+ assert.deepEqual(uncaught, [], `unexpected uncaught error: ${uncaught[0]?.stack || uncaught[0]}`);
78
+ assert.deepEqual(rejected, [], `unexpected unhandled rejection: ${rejected[0]?.stack || rejected[0]}`);
79
+ return result;
80
+ } finally {
81
+ process.removeListener('uncaughtException', onUncaught);
82
+ process.removeListener('unhandledRejection', onRejected);
83
+ }
84
+ }
85
+
86
+ function assertSpawnToolFailure(result) {
87
+ assert.equal(result.failurePhase, 'tool');
88
+ assert.equal(result.failureReason, 'spawn failed');
89
+ const status = _shellFailureStatus(result, 1000);
90
+ assert.equal(status.shellToolFailed, true);
91
+ const rendered = _composeShellFailure(
92
+ `[shell-tool-failed] ${status.statusDetail}`,
93
+ 'Error: ',
94
+ '',
95
+ result.stderr,
96
+ );
97
+ assert.match(rendered, /^Error: \[shell-tool-failed\] \[spawn failed\]/);
98
+ assert.equal(classifyToolFailure(rendered, 'shell'), 'tool-call/failure');
99
+ }
100
+
101
+ test('asynchronous ENOENT spawn errors remain shell tool failures', async () => {
102
+ const missing = await withoutUnhandledProcessFailure(() => execShellCommand({
103
+ shell: join(tmpdir(), `mixdog-missing-shell-${process.pid}`),
104
+ shellArg: '-c',
105
+ command: 'echo unreachable',
106
+ env: process.env,
107
+ cwd: process.cwd(),
108
+ timeoutMs: 1000,
109
+ }));
110
+ assertSpawnToolFailure(missing);
111
+ assert.match(missing.stderr, /ENOENT|not found/i);
112
+ });
113
+
114
+ test('asynchronous EACCES spawn errors remain shell tool failures', async (t) => {
115
+ if (process.platform === 'win32') return t.skip('executable-bit case is POSIX-only');
116
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-eacces-shell-'));
117
+ try {
118
+ const denied = join(dir, 'denied.sh');
119
+ writeFileSync(denied, '#!/bin/sh\necho unreachable\n');
120
+ chmodSync(denied, 0o600);
121
+ const result = await withoutUnhandledProcessFailure(() => execShellCommand({
122
+ shell: denied,
123
+ shellArg: '-c',
124
+ command: 'echo unreachable',
125
+ env: process.env,
126
+ cwd: process.cwd(),
127
+ timeoutMs: 1000,
128
+ }));
129
+ assertSpawnToolFailure(result);
130
+ assert.match(result.stderr, /EACCES|permission denied/i);
131
+ } finally {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test('execShellCommand carries cancellation cause alongside process signal', async () => {
137
+ const controller = new AbortController();
138
+ const isWindows = process.platform === 'win32';
139
+ const promise = execShellCommand({
140
+ shell: isWindows ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh',
141
+ shellArg: isWindows ? '/c' : '-c',
142
+ command: isWindows ? 'ping 127.0.0.1 -n 20 > nul' : 'sleep 10',
143
+ env: process.env,
144
+ cwd: process.cwd(),
145
+ timeoutMs: 5000,
146
+ abortSignal: controller.signal,
147
+ backgroundOnTimeout: false,
148
+ });
149
+ setTimeout(() => controller.abort(), 100);
150
+ const result = await promise;
151
+ assert.equal(result.killed, true);
152
+ assert.equal(result.killCause, 'cancellation');
153
+ assert.ok(result.signal || process.platform === 'win32');
154
+ });
155
+
156
+ test('cancellation racing with auto-background adoption is returned as cancelled', async () => {
157
+ let abortReads = 0;
158
+ const racingSignal = {
159
+ get aborted() { abortReads += 1; return abortReads >= 2; },
160
+ addEventListener() {},
161
+ removeEventListener() {},
162
+ };
163
+ const isWindows = process.platform === 'win32';
164
+ const result = await execShellCommand({
165
+ shell: isWindows ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh',
166
+ shellArg: isWindows ? '/c' : '-c',
167
+ command: isWindows ? 'ping 127.0.0.1 -n 20 > nul' : 'sleep 10',
168
+ env: process.env,
169
+ cwd: process.cwd(),
170
+ timeoutMs: 5000,
171
+ abortSignal: racingSignal,
172
+ autoBackgroundMs: 25,
173
+ backgroundOnTimeout: false,
174
+ });
175
+ assert.equal(result.backgrounded, false);
176
+ assert.equal(result.killed, true);
177
+ assert.equal(result.killCause, 'cancellation');
178
+ });
179
+
180
+ test('tool-failures separates actionable totals while retaining command-exit rows', () => {
181
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-tool-failures-test-'));
182
+ try {
183
+ const history = join(dir, 'history');
184
+ mkdirSync(history);
185
+ const rows = [
186
+ { ts: 1, tool_name: 'shell', category: 'process/signal', error_first_line: 'SIGKILL' },
187
+ { ts: 2, tool_name: 'shell', category: 'runtime/failure', error_first_line: 'capture guard' },
188
+ ...Array.from({ length: 45 }, (_, index) => ({
189
+ ts: index + 3,
190
+ tool_name: 'shell',
191
+ category: 'command-exit',
192
+ error_first_line: `exit ${index}`,
193
+ })),
194
+ ];
195
+ writeFileSync(join(history, 'tool-failures.jsonl'), `${rows.map(JSON.stringify).join('\n')}\n`);
196
+ const script = resolve('scripts/tool-failures.mjs');
197
+ const text = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2'], { encoding: 'utf8' });
198
+ assert.equal(text.status, 0, text.stderr);
199
+ assert.match(text.stdout, /actionable failures: 2\/2 shown/);
200
+ assert.match(text.stdout, /command exits: 2\/45 shown \(retained\)/);
201
+ assert.equal((text.stdout.match(/^- /gm) || []).length, 4);
202
+ const json = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2', '--json'], { encoding: 'utf8' });
203
+ assert.equal(json.status, 0, json.stderr);
204
+ const report = JSON.parse(json.stdout);
205
+ assert.deepEqual(report.actionable_failures, { shown: 2, matched: 2 });
206
+ assert.deepEqual(report.command_exits, { shown: 2, matched: 45 });
207
+ assert.equal(report.rows.length, 4);
208
+ } finally {
209
+ rmSync(dir, { recursive: true, force: true });
210
+ }
211
+ });
@@ -58,7 +58,7 @@ function runNode(args, label, timeoutMs = 180_000) {
58
58
  function runSmokeAll(iteration) {
59
59
  let totalMs = 0;
60
60
  const steps = [];
61
- for (const script of ['scripts/boot-smoke.mjs', 'scripts/tool-smoke.mjs']) {
61
+ for (const script of ['scripts/smoke.mjs', 'scripts/boot-smoke.mjs']) {
62
62
  const result = runNode([script], `${script} iteration ${iteration}`);
63
63
  totalMs += result.ms;
64
64
  steps.push({
@@ -161,8 +161,14 @@ while (Date.now() < deadline && iteration < maxIterations) {
161
161
  const iterStartedAt = Date.now();
162
162
  const smoke = runSmokeAll(iteration);
163
163
  smokeTimes.push(smoke.ms);
164
- const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1'], `failures iteration ${iteration}`, 60_000);
165
- if (!/tool failures:\s+0\/0 shown/.test(failure.stdout)) {
164
+ const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1', '--json'], `failures iteration ${iteration}`, 60_000);
165
+ let failureSummary;
166
+ try {
167
+ failureSummary = JSON.parse(failure.stdout);
168
+ } catch {
169
+ throw new Error(`tool failures returned malformed JSON:\n${failure.stdout}`);
170
+ }
171
+ if (failureSummary?.actionable_failures?.matched !== 0) {
166
172
  throw new Error(`tool failures appeared after loop start:\n${failure.stdout}`);
167
173
  }
168
174
  const currentRss = rssMb();