mixdog 0.9.47 → 0.9.49

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 (83) hide show
  1. package/package.json +14 -4
  2. package/scripts/agent-parallel-smoke.mjs +4 -1
  3. package/scripts/agent-route-batch-test.mjs +40 -0
  4. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  5. package/scripts/code-graph-description-contract.mjs +185 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  7. package/scripts/code-graph-dispatch-test.mjs +96 -0
  8. package/scripts/compact-pressure-test.mjs +40 -0
  9. package/scripts/deferred-tool-loading-test.mjs +233 -0
  10. package/scripts/execution-completion-dedup-test.mjs +48 -0
  11. package/scripts/explore-prompt-policy-test.mjs +88 -3
  12. package/scripts/live-worker-smoke.mjs +68 -16
  13. package/scripts/memory-core-input-test.mjs +33 -13
  14. package/scripts/native-edit-wire-test.mjs +152 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  16. package/scripts/patch-binary-cache-test.mjs +181 -0
  17. package/scripts/prompt-immediate-render-test.mjs +89 -0
  18. package/scripts/provider-toolcall-test.mjs +280 -15
  19. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  20. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  21. package/scripts/streaming-tail-window-test.mjs +29 -0
  22. package/scripts/tool-failures.mjs +21 -3
  23. package/scripts/tool-smoke.mjs +263 -38
  24. package/scripts/tool-tui-presentation-test.mjs +17 -1
  25. package/scripts/tui-perf-run.ps1 +26 -0
  26. package/scripts/tui-transcript-perf-test.mjs +7 -1
  27. package/scripts/verify-release-assets-test.mjs +647 -0
  28. package/scripts/verify-release-assets.mjs +293 -0
  29. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  30. package/src/cli.mjs +1 -0
  31. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  34. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  41. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  42. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  43. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  44. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  45. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  46. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  48. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  49. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  50. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  51. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  54. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  55. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  56. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  57. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  58. package/src/runtime/memory/tool-defs.mjs +1 -2
  59. package/src/session-runtime/context-status.mjs +25 -16
  60. package/src/session-runtime/model-route-api.mjs +2 -0
  61. package/src/session-runtime/runtime-core.mjs +2 -2
  62. package/src/session-runtime/session-turn-api.mjs +4 -1
  63. package/src/session-runtime/tool-catalog.mjs +113 -19
  64. package/src/session-runtime/tool-defs.mjs +1 -0
  65. package/src/standalone/agent-tool/helpers.mjs +25 -6
  66. package/src/standalone/agent-tool.mjs +75 -41
  67. package/src/tui/App.jsx +4 -0
  68. package/src/tui/app/input-parsers.mjs +8 -9
  69. package/src/tui/components/Markdown.jsx +6 -1
  70. package/src/tui/components/Message.jsx +11 -2
  71. package/src/tui/components/PromptInput.jsx +19 -21
  72. package/src/tui/components/Spinner.jsx +4 -4
  73. package/src/tui/components/StatusLine.jsx +6 -6
  74. package/src/tui/components/TranscriptItem.jsx +2 -2
  75. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  76. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  77. package/src/tui/dist/index.mjs +130 -45
  78. package/src/tui/engine/agent-job-feed.mjs +21 -2
  79. package/src/tui/engine/turn.mjs +12 -1
  80. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  81. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  82. package/src/ui/statusline.mjs +5 -5
  83. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -0,0 +1,181 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ mkdirSync, mkdtempSync, rmSync, writeFileSync,
5
+ } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import test from 'node:test';
9
+ import {
10
+ ensurePatchBinary,
11
+ findCachedPatchBinary,
12
+ } from '../src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs';
13
+ import {
14
+ ensureNativePatchBinaryAvailable,
15
+ nativePatchBinPath,
16
+ } from '../src/runtime/agent/orchestrator/tools/patch/native-server.mjs';
17
+
18
+ const pkey = `${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}`;
19
+ const suffix = process.platform === 'win32' ? '.exe' : '';
20
+
21
+ function sha256(body) {
22
+ return createHash('sha256').update(body).digest('hex');
23
+ }
24
+
25
+ function makeManifest(version, body, url = `https://github.com/tribgames/mixdog/releases/download/patch-v${version}/mixdog-patch-${pkey}${suffix}`) {
26
+ return {
27
+ version,
28
+ assets: { [pkey]: { url, sha256: sha256(body) } },
29
+ };
30
+ }
31
+
32
+ function fixture(t) {
33
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-patch-cache-'));
34
+ const binDir = join(dataDir, 'patch-bin');
35
+ mkdirSync(binDir, { recursive: true });
36
+ t.after(() => rmSync(dataDir, { recursive: true, force: true }));
37
+ return {
38
+ dataDir,
39
+ binDir,
40
+ bin(version) {
41
+ return join(binDir, `mixdog-patch-${version}${suffix}`);
42
+ },
43
+ localBin: join(dataDir, 'local-build', `mixdog-patch${suffix}`),
44
+ };
45
+ }
46
+
47
+ function nativeOptions(f, bundledManifest, download) {
48
+ return {
49
+ dataDir: f.dataDir,
50
+ defaultBin: f.localBin,
51
+ fetcherOptions: { bundledManifest, download },
52
+ };
53
+ }
54
+
55
+ test('stale cached manifest and binary cannot bypass native resolution', async (t) => {
56
+ const f = fixture(t);
57
+ const oldBody = Buffer.from('old binary');
58
+ const bundledBody = Buffer.from('bundled binary');
59
+ const bundledManifest = makeManifest('2.0.0', bundledBody);
60
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(makeManifest('1.0.0', oldBody)));
61
+ writeFileSync(f.bin('1.0.0'), oldBody);
62
+ const options = nativeOptions(f, bundledManifest, async (_url, destination) => {
63
+ writeFileSync(destination, bundledBody);
64
+ });
65
+
66
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), null);
67
+ assert.equal(nativePatchBinPath(options), f.localBin);
68
+ assert.equal(await ensureNativePatchBinaryAvailable(options), f.bin('2.0.0'));
69
+ });
70
+
71
+ test('wrong-hash binary cannot bypass native resolution', async (t) => {
72
+ const f = fixture(t);
73
+ const expectedBody = Buffer.from('expected binary');
74
+ const manifest = makeManifest('2.0.0', expectedBody);
75
+ writeFileSync(f.bin('2.0.0'), 'wrong binary');
76
+ const options = nativeOptions(f, manifest, async (_url, destination) => {
77
+ writeFileSync(destination, expectedBody);
78
+ });
79
+
80
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest: manifest }), null);
81
+ assert.equal(nativePatchBinPath(options), f.localBin);
82
+ assert.equal(await ensureNativePatchBinaryAvailable(options), f.bin('2.0.0'));
83
+ });
84
+
85
+ test('exact bundled filename with the correct hash is selected', (t) => {
86
+ const f = fixture(t);
87
+ const body = Buffer.from('correct binary');
88
+ const manifest = makeManifest('2.0.0', body);
89
+ writeFileSync(f.bin('2.0.0'), body);
90
+
91
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest: manifest }), f.bin('2.0.0'));
92
+ });
93
+
94
+ test('upgrade downloads the bundled version through the network-free seam', async (t) => {
95
+ const f = fixture(t);
96
+ const oldBody = Buffer.from('old binary');
97
+ const newBody = Buffer.from('new binary');
98
+ const oldManifest = makeManifest('1.0.0', oldBody);
99
+ const bundledManifest = makeManifest('2.0.0', newBody);
100
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(oldManifest));
101
+ writeFileSync(f.bin('1.0.0'), oldBody);
102
+ let downloads = 0;
103
+
104
+ const selected = await ensurePatchBinary(f.dataDir, {
105
+ bundledManifest,
106
+ download: async (url, destination) => {
107
+ downloads += 1;
108
+ assert.equal(url, bundledManifest.assets[pkey].url);
109
+ writeFileSync(destination, newBody);
110
+ },
111
+ });
112
+
113
+ assert.equal(downloads, 1);
114
+ assert.equal(selected, f.bin('2.0.0'));
115
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), selected);
116
+ });
117
+
118
+ test('only a trusted, strictly newer valid cached manifest may advance policy', (t) => {
119
+ const f = fixture(t);
120
+ const bundledBody = Buffer.from('bundled binary');
121
+ const newerBody = Buffer.from('newer binary');
122
+ const bundledManifest = makeManifest('2.0.0', bundledBody);
123
+ const newerManifest = makeManifest('2.1.0', newerBody);
124
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(newerManifest));
125
+ writeFileSync(f.bin('2.1.0'), newerBody);
126
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), f.bin('2.1.0'));
127
+
128
+ newerManifest.assets[pkey].url = 'https://example.com/untrusted';
129
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(newerManifest));
130
+ writeFileSync(f.bin('2.0.0'), bundledBody);
131
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), f.bin('2.0.0'));
132
+ });
133
+
134
+ test('equal-version cached manifest cannot replace bundled policy', (t) => {
135
+ const f = fixture(t);
136
+ const bundledBody = Buffer.from('bundled binary');
137
+ const bundledManifest = makeManifest('2.0.0', bundledBody);
138
+ const equalManifest = makeManifest('2.0.0', Buffer.from('different binary'));
139
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(equalManifest));
140
+ writeFileSync(f.bin('2.0.0'), bundledBody);
141
+
142
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), f.bin('2.0.0'));
143
+ });
144
+
145
+ for (const [label, badUrl] of [
146
+ ['wrong release tag', `https://github.com/tribgames/mixdog/releases/download/patch-v9.9.9/mixdog-patch-${pkey}${suffix}`],
147
+ ['wrong platform filename', `https://github.com/tribgames/mixdog/releases/download/patch-v2.1.0/mixdog-patch-wrong-platform${suffix}`],
148
+ ]) {
149
+ test(`newer cached manifest with ${label} cannot replace bundled policy`, (t) => {
150
+ const f = fixture(t);
151
+ const bundledBody = Buffer.from('bundled binary');
152
+ const newerBody = Buffer.from('newer binary');
153
+ const bundledManifest = makeManifest('2.0.0', bundledBody);
154
+ const newerManifest = makeManifest('2.1.0', newerBody, badUrl);
155
+ writeFileSync(join(f.binDir, 'manifest.json'), JSON.stringify(newerManifest));
156
+ writeFileSync(f.bin('2.0.0'), bundledBody);
157
+ writeFileSync(f.bin('2.1.0'), newerBody);
158
+
159
+ assert.equal(findCachedPatchBinary(f.dataDir, { bundledManifest }), f.bin('2.0.0'));
160
+ });
161
+ }
162
+
163
+ test('explicit native override and local cargo path retain precedence', (t) => {
164
+ const f = fixture(t);
165
+ const body = Buffer.from('cached binary');
166
+ const bundledManifest = makeManifest('2.0.0', body);
167
+ writeFileSync(f.bin('2.0.0'), body);
168
+ mkdirSync(join(f.dataDir, 'local-build'), { recursive: true });
169
+ writeFileSync(f.localBin, 'local binary');
170
+ const options = nativeOptions(f, bundledManifest);
171
+
172
+ assert.equal(nativePatchBinPath(options), f.localBin);
173
+ const previous = process.env.MIXDOG_PATCH_NATIVE_BIN;
174
+ process.env.MIXDOG_PATCH_NATIVE_BIN = join(f.dataDir, 'explicit-binary');
175
+ try {
176
+ assert.equal(nativePatchBinPath(options), process.env.MIXDOG_PATCH_NATIVE_BIN);
177
+ } finally {
178
+ if (previous === undefined) delete process.env.MIXDOG_PATCH_NATIVE_BIN;
179
+ else process.env.MIXDOG_PATCH_NATIVE_BIN = previous;
180
+ }
181
+ });
@@ -0,0 +1,89 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ cancelPromptImmediateFlush,
6
+ schedulePromptImmediateFlush,
7
+ } from '../src/tui/components/prompt-input/immediate-render.mjs';
8
+
9
+ test('busy prompt input cancels a pending immediate render and schedules nothing', () => {
10
+ const throttle = { lastAt: 90, timer: 7 };
11
+ const cleared = [];
12
+ let queued = 0;
13
+ let flushed = 0;
14
+
15
+ const scheduled = schedulePromptImmediateFlush({
16
+ throttle,
17
+ isSuppressed: true,
18
+ flush: () => { flushed += 1; },
19
+ now: () => 100,
20
+ enqueue: () => { queued += 1; },
21
+ clearTimer: (timer) => cleared.push(timer),
22
+ });
23
+
24
+ assert.equal(scheduled, false);
25
+ assert.equal(throttle.timer, null);
26
+ assert.deepEqual(cleared, [7]);
27
+ assert.equal(queued, 0);
28
+ assert.equal(flushed, 0);
29
+ });
30
+
31
+ test('idle prompt input keeps one leading and one coalesced trailing immediate render', () => {
32
+ const throttle = { lastAt: 0, timer: null };
33
+ let current = 100;
34
+ const queued = [];
35
+ const timers = [];
36
+ let flushed = 0;
37
+ const options = {
38
+ throttle,
39
+ isSuppressed: false,
40
+ flush: () => { flushed += 1; },
41
+ now: () => current,
42
+ enqueue: (callback) => queued.push(callback),
43
+ setTimer: (callback, delay) => {
44
+ timers.push({ callback, delay });
45
+ return timers.length;
46
+ },
47
+ };
48
+
49
+ assert.equal(schedulePromptImmediateFlush(options), true);
50
+ assert.equal(queued.length, 1);
51
+ queued.shift()();
52
+ assert.equal(flushed, 1);
53
+
54
+ current = 105;
55
+ assert.equal(schedulePromptImmediateFlush(options), true);
56
+ current = 106;
57
+ assert.equal(schedulePromptImmediateFlush(options), false);
58
+ assert.equal(timers.length, 1);
59
+ assert.equal(timers[0].delay, 11);
60
+
61
+ current = 116;
62
+ timers[0].callback();
63
+ assert.equal(flushed, 2);
64
+ assert.equal(throttle.timer, null);
65
+ });
66
+
67
+ test('a trailing immediate render is dropped if the turn becomes busy', () => {
68
+ const throttle = { lastAt: 100, timer: null };
69
+ let busy = false;
70
+ let timerCallback;
71
+ let flushed = 0;
72
+
73
+ schedulePromptImmediateFlush({
74
+ throttle,
75
+ isSuppressed: () => busy,
76
+ flush: () => { flushed += 1; },
77
+ now: () => 105,
78
+ setTimer: (callback) => {
79
+ timerCallback = callback;
80
+ return 1;
81
+ },
82
+ });
83
+ busy = true;
84
+ timerCallback();
85
+
86
+ assert.equal(flushed, 0);
87
+ assert.equal(throttle.timer, null);
88
+ assert.equal(cancelPromptImmediateFlush(throttle), false);
89
+ });
@@ -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).