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,293 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ export const PATCH_PLATFORMS = {
6
+ 'darwin-arm64': 'mixdog-patch-darwin-arm64',
7
+ 'darwin-x64': 'mixdog-patch-darwin-x64',
8
+ 'linux-arm64': 'mixdog-patch-linux-arm64',
9
+ 'linux-x64': 'mixdog-patch-linux-x64',
10
+ 'win32-x64': 'mixdog-patch-win32-x64.exe',
11
+ };
12
+
13
+ export const GRAPH_PLATFORMS = {
14
+ 'darwin-arm64': 'mixdog-graph-darwin-arm64',
15
+ 'darwin-x64': 'mixdog-graph-darwin-x64',
16
+ 'linux-arm64': 'mixdog-graph-linux-arm64',
17
+ 'linux-x64': 'mixdog-graph-linux-x64',
18
+ 'win32-x64': 'mixdog-graph-win32-x64.exe',
19
+ };
20
+
21
+ const RUNTIME_PLATFORMS = [
22
+ 'linux-x64',
23
+ 'linux-arm64',
24
+ 'darwin-x64',
25
+ 'darwin-arm64',
26
+ 'win32-x64',
27
+ ];
28
+ const STRICT_VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
29
+ const SHA256 = /^[a-f0-9]{64}$/i;
30
+ export const MAX_ASSET_BYTES = 256 * 1024 * 1024;
31
+ export const MAX_DOWNLOAD_TIMEOUT_MS = 300_000;
32
+ const PATCH_MANIFEST_PATH = 'src/runtime/agent/orchestrator/tools/patch-manifest.json';
33
+ const PATCH_CARGO_PATH = 'native/mixdog-patch/Cargo.toml';
34
+ const RUNTIME_MANIFEST_PATH = 'src/runtime/memory/data/runtime-manifest.json';
35
+ const GRAPH_MANIFEST_PATH = 'src/runtime/agent/orchestrator/tools/graph-manifest.json';
36
+ const PACKAGE_PATH = 'package.json';
37
+
38
+ function assertPlainObject(value, label) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
40
+ throw new Error(`${label} must be an object`);
41
+ }
42
+ }
43
+
44
+ function assertExactKeys(value, expected, label) {
45
+ const actual = Object.keys(value).sort();
46
+ const wanted = [...expected].sort();
47
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
48
+ throw new Error(`${label} keys must be exactly: ${wanted.join(', ')}`);
49
+ }
50
+ }
51
+
52
+ export function validatePatchManifest(manifest, cargoToml) {
53
+ assertPlainObject(manifest, 'Patch manifest');
54
+ assertExactKeys(manifest, ['version', '_comment', 'assets'], 'Patch manifest');
55
+ if (typeof manifest.version !== 'string' || !STRICT_VERSION.test(manifest.version)) {
56
+ throw new Error(`Patch manifest version is not strict MAJOR.MINOR.PATCH: ${manifest.version}`);
57
+ }
58
+ if (typeof manifest._comment !== 'string' || !manifest._comment) {
59
+ throw new Error('Patch manifest _comment must be a non-empty string');
60
+ }
61
+ assertPlainObject(manifest.assets, 'Patch manifest assets');
62
+ assertExactKeys(manifest.assets, Object.keys(PATCH_PLATFORMS), 'Patch manifest assets');
63
+
64
+ let inPackage = false;
65
+ let cargoVersion;
66
+ for (const line of String(cargoToml).split(/\r?\n/)) {
67
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/)?.[1];
68
+ if (section) {
69
+ inPackage = section === 'package';
70
+ continue;
71
+ }
72
+ if (inPackage) {
73
+ const version = line.match(/^\s*version\s*=\s*"([^"]+)"\s*$/)?.[1];
74
+ if (version) {
75
+ cargoVersion = version;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ if (!cargoVersion) throw new Error('Could not read [package] version from native/mixdog-patch/Cargo.toml');
81
+ if (cargoVersion !== manifest.version) {
82
+ throw new Error(`Patch Cargo version ${cargoVersion} does not match manifest version ${manifest.version}`);
83
+ }
84
+
85
+ for (const [platform, filename] of Object.entries(PATCH_PLATFORMS)) {
86
+ const asset = manifest.assets[platform];
87
+ assertPlainObject(asset, `Patch asset ${platform}`);
88
+ assertExactKeys(asset, ['url', 'sha256'], `Patch asset ${platform}`);
89
+ if (typeof asset.url !== 'string') throw new Error(`${platform}: patch asset URL must be a string`);
90
+ let url;
91
+ try {
92
+ url = new URL(asset.url);
93
+ } catch {
94
+ throw new Error(`${platform}: invalid patch asset URL`);
95
+ }
96
+ const expectedPath = `/tribgames/mixdog/releases/download/patch-v${manifest.version}/${filename}`;
97
+ if (
98
+ url.protocol !== 'https:'
99
+ || url.hostname !== 'github.com'
100
+ || url.port
101
+ || url.username
102
+ || url.password
103
+ || url.search
104
+ || url.hash
105
+ || url.pathname !== expectedPath
106
+ ) {
107
+ throw new Error(`${platform}: patch asset URL must be https://github.com${expectedPath}`);
108
+ }
109
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
110
+ throw new Error(`${platform}: invalid patch asset sha256`);
111
+ }
112
+ }
113
+ return manifest;
114
+ }
115
+
116
+ export function validateRuntimeManifest(manifest) {
117
+ assertPlainObject(manifest, 'Runtime manifest');
118
+ if (!manifest.release_tag || !manifest.assets || typeof manifest.assets !== 'object') {
119
+ throw new Error(`${RUNTIME_MANIFEST_PATH} is missing release_tag or assets`);
120
+ }
121
+ assertPlainObject(manifest.assets, 'Runtime manifest assets');
122
+ assertExactKeys(manifest.assets, RUNTIME_PLATFORMS, 'Runtime manifest assets');
123
+ for (const [platform, asset] of Object.entries(manifest.assets)) {
124
+ if (typeof asset?.url !== 'string' || new URL(asset.url).protocol !== 'https:') {
125
+ throw new Error(`${platform}: invalid HTTPS asset URL`);
126
+ }
127
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
128
+ throw new Error(`${platform}: invalid sha256`);
129
+ }
130
+ if (!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ASSET_BYTES) {
131
+ throw new Error(`${platform}: invalid size`);
132
+ }
133
+ }
134
+ return manifest;
135
+ }
136
+
137
+ export function validateGraphManifest(manifest, packageJson) {
138
+ assertPlainObject(manifest, 'Graph manifest');
139
+ assertExactKeys(manifest, ['version', '_comment', 'assets'], 'Graph manifest');
140
+ if (typeof manifest.version !== 'string' || !STRICT_VERSION.test(manifest.version)) {
141
+ throw new Error(`Graph manifest version is not strict MAJOR.MINOR.PATCH: ${manifest.version}`);
142
+ }
143
+ if (typeof manifest._comment !== 'string' || !manifest._comment) {
144
+ throw new Error('Graph manifest _comment must be a non-empty string');
145
+ }
146
+ assertPlainObject(packageJson, 'package.json');
147
+ if (typeof packageJson.version !== 'string' || !STRICT_VERSION.test(packageJson.version)) {
148
+ throw new Error(`package.json version is not strict MAJOR.MINOR.PATCH: ${packageJson.version}`);
149
+ }
150
+ assertPlainObject(manifest.assets, 'Graph manifest assets');
151
+ assertExactKeys(manifest.assets, Object.keys(GRAPH_PLATFORMS), 'Graph manifest assets');
152
+
153
+ for (const [platform, filename] of Object.entries(GRAPH_PLATFORMS)) {
154
+ const asset = manifest.assets[platform];
155
+ assertPlainObject(asset, `Graph asset ${platform}`);
156
+ assertExactKeys(asset, ['url', 'sha256'], `Graph asset ${platform}`);
157
+ if (typeof asset.url !== 'string') throw new Error(`${platform}: graph asset URL must be a string`);
158
+ let url;
159
+ try {
160
+ url = new URL(asset.url);
161
+ } catch {
162
+ throw new Error(`${platform}: invalid graph asset URL`);
163
+ }
164
+ const expectedPath = `/tribgames/mixdog/releases/download/graph-v${manifest.version}/${filename}`;
165
+ if (
166
+ url.protocol !== 'https:'
167
+ || url.hostname !== 'github.com'
168
+ || url.port
169
+ || url.username
170
+ || url.password
171
+ || url.search
172
+ || url.hash
173
+ || url.pathname !== expectedPath
174
+ ) {
175
+ throw new Error(`${platform}: graph asset URL must be https://github.com${expectedPath}`);
176
+ }
177
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
178
+ throw new Error(`${platform}: invalid graph asset sha256`);
179
+ }
180
+ }
181
+ return manifest;
182
+ }
183
+
184
+ async function downloadAndVerify(label, asset, fetchImpl, timeoutMs, maxAssetBytes) {
185
+ console.log(`Downloading and verifying ${label}`);
186
+ const controller = new AbortController();
187
+ const response = await fetchImpl(asset.url, {
188
+ redirect: 'follow',
189
+ headers: { 'User-Agent': 'mixdog-release-asset-guard' },
190
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
191
+ });
192
+ if (!response.ok || !response.body) {
193
+ await response.body?.cancel();
194
+ throw new Error(`download failed with HTTP ${response.status}`);
195
+ }
196
+ const hash = createHash('sha256');
197
+ let downloaded = 0;
198
+ const byteCeiling = Math.min(asset.size ?? Number.POSITIVE_INFINITY, maxAssetBytes);
199
+ const reader = response.body.getReader();
200
+ try {
201
+ while (true) {
202
+ const { done, value: chunk } = await reader.read();
203
+ if (done) break;
204
+ if (downloaded + chunk.byteLength > byteCeiling) {
205
+ const error = new Error(`byte ceiling exceeded (${byteCeiling} bytes)`);
206
+ controller.abort(error);
207
+ await reader.cancel(error).catch(() => {});
208
+ throw error;
209
+ }
210
+ hash.update(chunk);
211
+ downloaded += chunk.byteLength;
212
+ }
213
+ } finally {
214
+ reader.releaseLock();
215
+ }
216
+ const digest = hash.digest('hex');
217
+ if (asset.size !== undefined && downloaded !== asset.size) {
218
+ throw new Error(`size mismatch (manifest ${asset.size}, downloaded ${downloaded})`);
219
+ }
220
+ if (digest.toLowerCase() !== asset.sha256.toLowerCase()) {
221
+ throw new Error(`sha256 mismatch (manifest ${asset.sha256}, downloaded ${digest})`);
222
+ }
223
+ }
224
+
225
+ export async function verifyAssetDownloads(
226
+ assets,
227
+ {
228
+ fetchImpl = globalThis.fetch,
229
+ attempts = 3,
230
+ timeoutMs = 300_000,
231
+ maxAssetBytes = MAX_ASSET_BYTES,
232
+ retryDelay = (attempt) => (
233
+ new Promise((resolve) => setTimeout(resolve, attempt === 1 ? 2_000 : 6_000))
234
+ ),
235
+ } = {},
236
+ ) {
237
+ if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 5) {
238
+ throw new Error(`Download attempts must be between 1 and 5, got ${attempts}`);
239
+ }
240
+ if (!Number.isSafeInteger(maxAssetBytes) || maxAssetBytes < 1 || maxAssetBytes > MAX_ASSET_BYTES) {
241
+ throw new Error(`Asset byte ceiling must be between 1 and ${MAX_ASSET_BYTES}, got ${maxAssetBytes}`);
242
+ }
243
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_DOWNLOAD_TIMEOUT_MS) {
244
+ throw new Error(`Download timeout must be between 1 and ${MAX_DOWNLOAD_TIMEOUT_MS}, got ${timeoutMs}`);
245
+ }
246
+ for (const [label, asset] of Object.entries(assets)) {
247
+ let lastError;
248
+ for (let attempt = 1; attempt <= attempts; attempt++) {
249
+ try {
250
+ await downloadAndVerify(label, asset, fetchImpl, timeoutMs, maxAssetBytes);
251
+ lastError = undefined;
252
+ break;
253
+ } catch (error) {
254
+ lastError = error;
255
+ console.warn(`${label}: attempt ${attempt}/${attempts} failed: ${error.message}`);
256
+ if (attempt < attempts) await retryDelay(attempt);
257
+ }
258
+ }
259
+ if (lastError) {
260
+ throw new Error(`${label}: verification failed after ${attempts} attempts: ${lastError.message}`);
261
+ }
262
+ }
263
+ }
264
+
265
+ export async function verifyReleaseAssets({
266
+ patchManifestPath = PATCH_MANIFEST_PATH,
267
+ cargoPath = PATCH_CARGO_PATH,
268
+ runtimeManifestPath = RUNTIME_MANIFEST_PATH,
269
+ graphManifestPath = GRAPH_MANIFEST_PATH,
270
+ packagePath = PACKAGE_PATH,
271
+ downloadOptions,
272
+ } = {}) {
273
+ const [patchSource, cargoToml, runtimeSource, graphSource, packageSource] = await Promise.all([
274
+ readFile(patchManifestPath, 'utf8'),
275
+ readFile(cargoPath, 'utf8'),
276
+ readFile(runtimeManifestPath, 'utf8'),
277
+ readFile(graphManifestPath, 'utf8'),
278
+ readFile(packagePath, 'utf8'),
279
+ ]);
280
+ const patchManifest = validatePatchManifest(JSON.parse(patchSource), cargoToml);
281
+ const runtimeManifest = validateRuntimeManifest(JSON.parse(runtimeSource));
282
+ const graphManifest = validateGraphManifest(JSON.parse(graphSource), JSON.parse(packageSource));
283
+ await verifyAssetDownloads(patchManifest.assets, downloadOptions);
284
+ console.log(`Verified all bundled patch assets for patch-v${patchManifest.version}.`);
285
+ await verifyAssetDownloads(runtimeManifest.assets, downloadOptions);
286
+ console.log(`Verified all bundled runtime assets for ${runtimeManifest.release_tag}.`);
287
+ await verifyAssetDownloads(graphManifest.assets, downloadOptions);
288
+ console.log(`Verified all bundled graph assets for graph-v${graphManifest.version}.`);
289
+ }
290
+
291
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
292
+ await verifyReleaseAssets();
293
+ }
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import test from 'node:test';
6
+
7
+ const root = fileURLToPath(new URL('..', import.meta.url));
8
+
9
+ function source(relativePath) {
10
+ return readFileSync(join(root, relativePath), 'utf8');
11
+ }
12
+
13
+ test('Windows-sensitive Node re-execs keep their windows hidden', () => {
14
+ const cli = source('src/cli.mjs');
15
+ const jitRebuild = source('src/tui/dev/jit-rebuild.mjs');
16
+
17
+ assert.match(cli, /spawnSync\(process\.execPath, \[fileURLToPath\(import\.meta\.url\), \.\.\.argv\], \{\r?\n\s*stdio: 'inherit',\r?\n\s*env: \{ \.\.\.process\.env, MIXDOG_SWAP_REEXEC: '1' \},\r?\n\s*windowsHide: true,\r?\n\s*\}\)/);
18
+ assert.match(jitRebuild, /spawnSync\(process\.execPath, \[script\], \{\r?\n\s*stdio: process\.env\.MIXDOG_TUI_DEV_VERBOSE \? 'inherit' : 'ignore',\r?\n\s*windowsHide: true,\r?\n\s*\}\)/);
19
+ });
package/src/cli.mjs CHANGED
@@ -35,6 +35,7 @@ async function main() {
35
35
  const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...argv], {
36
36
  stdio: 'inherit',
37
37
  env: { ...process.env, MIXDOG_SWAP_REEXEC: '1' },
38
+ windowsHide: true,
38
39
  });
39
40
  if (!r.error) return Number.isInteger(r.status) ? r.status : 0;
40
41
  } catch { /* fall through to in-place run */ }
@@ -245,15 +245,26 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
245
245
  }
246
246
 
247
247
  function classifyToolFailure(resultText, toolName) {
248
- const text = String(resultText ?? '').toLowerCase();
249
- if (/\[shell-tool-failed\]/i.test(String(resultText ?? ''))) return 'tool-call/failure';
250
- if (/\[shell-run-failed\]/i.test(String(resultText ?? ''))) {
251
- if (/timeout|timed out|aborted|interrupted/.test(text)) return 'timeout/abort';
248
+ const raw = String(resultText ?? '');
249
+ const text = raw.toLowerCase();
250
+ // Shell renderers put the machine-readable status on the leading line.
251
+ // Only inspect that marker: command text and stderr frequently contain
252
+ // words such as "timeout" or "aborted" and must not rewrite a real exit
253
+ // code into a runtime failure category.
254
+ const leading = raw.split(/\r?\n/)
255
+ .map((line) => line.trim())
256
+ .find((line) => line && !line.startsWith('⚠️ '))
257
+ ?.replace(/^Error:\s*/i, '') || '';
258
+ if (/^\[shell-tool-failed\](?:\s|$)/i.test(leading)) return 'tool-call/failure';
259
+ if (/^\[shell-run-failed\](?:\s|$)/i.test(leading)) {
260
+ if (/\[timeout:|cause:\s*(?:timeout|cancellation)\b/i.test(leading)) return 'timeout/abort';
261
+ if (/cause:\s*(?:output-limit|output-capture-error|background-adoption-failed)\b/i.test(leading)) return 'runtime/failure';
262
+ if (/\[signal:\s*[^\]]+/i.test(leading)) return 'process/signal';
252
263
  return 'command-exit';
253
264
  }
254
265
  if (/requires either|invalid arguments|unknown parameter|must be|schema|expected|required|old_string is .*>=/.test(text)) return 'schema/args';
255
266
  if (/not in allow-list|not allowed/.test(text)) return 'permission';
256
- if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(String(resultText ?? ''))) return 'command-exit';
267
+ if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(raw)) return 'command-exit';
257
268
  if (/enoent|cannot find|not found at this path|path does not exist|no such file|file not found in graph|unreadable/.test(text)) return 'path/enoent';
258
269
  if (/timed out|timeout|interrupted|aborted/.test(text)) return 'timeout/abort';
259
270
  if (/hunk rejected|patch failed|context mismatch|expected first old\/context|context not found/.test(text)) return 'patch/context';
@@ -246,7 +246,7 @@ function _sanitizeInputSchema(schema, toolName) {
246
246
  // None of the branches' required lists are hoisted — callers that relied
247
247
  // on discriminated-union semantics will still function; the model simply
248
248
  // receives a union of the property surface with no hard-required constraint.
249
- const mergedProps = {};
249
+ const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
250
250
  const branchDescs = [];
251
251
  for (const branch of Array.isArray(compound) ? compound : []) {
252
252
  if (branch && typeof branch === 'object' && branch.properties) {
@@ -317,7 +317,26 @@ function nativeAnthropicTools(opts) {
317
317
  function toAnthropicToolChoice(toolChoice) {
318
318
  return toolChoice === 'none' ? { type: 'none' } : undefined;
319
319
  }
320
- function deferredAnthropicTools(activeTools, opts) {
320
+ function discoveredAnthropicToolNames(messages, opts, provider) {
321
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
322
+ const discovered = new Set(
323
+ Array.isArray(opts?.session?.deferredDiscoveredTools)
324
+ ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
325
+ : [],
326
+ );
327
+ for (const message of Array.isArray(messages) ? messages : []) {
328
+ const native = message?.nativeToolSearch;
329
+ const source = String(native?.provider || '').toLowerCase();
330
+ if (source && source !== provider
331
+ && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
332
+ for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
333
+ const key = String(name || '').trim();
334
+ if (key) discovered.add(key);
335
+ }
336
+ }
337
+ return discovered;
338
+ }
339
+ function deferredAnthropicTools(activeTools, messages, opts) {
321
340
  if (opts?.session?.deferredNativeTools !== true) return [];
322
341
  // A request whose ONLY tools are deferred is rejected by the API with
323
342
  // `400: At least one tool must have defer_loading=false` — happens on the
@@ -325,9 +344,10 @@ function deferredAnthropicTools(activeTools, opts) {
325
344
  // No active tools ⇒ send no deferred catalog either.
326
345
  if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
327
346
  const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
347
+ const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic-oauth');
328
348
  const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
329
349
  return catalog
330
- .filter((tool) => tool?.name && !active.has(String(tool.name)))
350
+ .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
331
351
  .map((tool) => ({ ...tool, deferLoading: true }));
332
352
  }
333
353
 
@@ -374,15 +394,19 @@ function toAnthropicMessages(messages) {
374
394
 
375
395
  if (m.role === 'tool') {
376
396
  const last = result[result.length - 1];
377
- // Do not replay native deferred-loader `tool_reference` blocks from
378
- // prior turns. They are tied to the exact Anthropic deferred-tool
379
- // request that produced them; after /model or provider switches the
380
- // new request may not carry the matching deferred tool surface and
381
- // Anthropic rejects the history as a request validation error.
397
+ const native = m.nativeToolSearch;
398
+ const nativeProvider = String(native?.provider || '').toLowerCase();
399
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
400
+ const references = (!nativeProvider || anthropicNative.has(nativeProvider))
401
+ && Array.isArray(native?.toolReferences)
402
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
403
+ : [];
382
404
  const block = {
383
405
  type: 'tool_result',
384
406
  tool_use_id: m.toolCallId || '',
385
- content: normalizeContentForAnthropic(m.content),
407
+ content: references.length
408
+ ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
409
+ : normalizeContentForAnthropic(m.content),
386
410
  };
387
411
  if (last?.role === 'user' && Array.isArray(last.content)) {
388
412
  last.content.push(block);
@@ -627,7 +651,7 @@ function buildRequestBody(messages, model, tools, sendOpts) {
627
651
  if (systemBlocks.length) body.system = systemBlocks;
628
652
 
629
653
  const nativeTools = nativeAnthropicTools(opts);
630
- const deferredTools = deferredAnthropicTools(tools || [], opts);
654
+ const deferredTools = deferredAnthropicTools(tools || [], chatMsgs, opts);
631
655
  if (tools?.length || nativeTools.length || deferredTools.length) {
632
656
  // No cache_control on tools — the systemBase BP already covers the
633
657
  // tools prefix via Anthropic's prompt cache prefix semantics (order:
@@ -1329,4 +1353,4 @@ export { parseSSEStream, _classifyMidstreamError, ANTHROPIC_MAX_MIDSTREAM_RETRIE
1329
1353
 
1330
1354
  // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
1331
1355
  // catalog-driven max-tokens resolution without duplicating its logic.
1332
- export const _test = { resolveMaxTokens };
1356
+ export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
@@ -231,7 +231,7 @@ function resolveMaxTokens(model) {
231
231
  }
232
232
 
233
233
  // Test-only escape hatch for scripts/anthropic-maxtokens-test.mjs.
234
- export const _test = { resolveMaxTokens };
234
+ export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
235
235
 
236
236
  const MIN_THINKING_BUDGET = 1024;
237
237
  const THINKING_OUTPUT_RESERVE = 1024;
@@ -252,7 +252,7 @@ function _sanitizeInputSchema(schema, toolName) {
252
252
  }
253
253
  const compound = schema.oneOf || schema.anyOf || schema.allOf;
254
254
  if (!compound) return structuredClone(schema);
255
- const mergedProps = {};
255
+ const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
256
256
  const branchDescs = [];
257
257
  for (const branch of Array.isArray(compound) ? compound : []) {
258
258
  if (branch && typeof branch === 'object' && branch.properties) {
@@ -324,16 +324,36 @@ function nativeAnthropicTools(opts) {
324
324
  function toAnthropicToolChoice(toolChoice) {
325
325
  return toolChoice === 'none' ? { type: 'none' } : undefined;
326
326
  }
327
- function deferredAnthropicTools(activeTools, opts) {
327
+ function discoveredAnthropicToolNames(messages, opts, provider) {
328
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
329
+ const discovered = new Set(
330
+ Array.isArray(opts?.session?.deferredDiscoveredTools)
331
+ ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
332
+ : [],
333
+ );
334
+ for (const message of Array.isArray(messages) ? messages : []) {
335
+ const native = message?.nativeToolSearch;
336
+ const source = String(native?.provider || '').toLowerCase();
337
+ if (source && source !== provider
338
+ && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
339
+ for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
340
+ const key = String(name || '').trim();
341
+ if (key) discovered.add(key);
342
+ }
343
+ }
344
+ return discovered;
345
+ }
346
+ function deferredAnthropicTools(activeTools, messages, opts) {
328
347
  if (opts?.session?.deferredNativeTools !== true) return [];
329
348
  // Guard against an all-deferred tools array — the API rejects it with
330
349
  // `400: At least one tool must have defer_loading=false` (iteration-cap
331
350
  // final turn sends tools: []). See anthropic-oauth.mjs counterpart.
332
351
  if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
333
352
  const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
353
+ const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic');
334
354
  const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
335
355
  return catalog
336
- .filter((tool) => tool?.name && !active.has(String(tool.name)))
356
+ .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
337
357
  .map((tool) => ({ ...tool, deferLoading: true }));
338
358
  }
339
359
  function toAnthropicMessages(messages) {
@@ -376,15 +396,19 @@ function toAnthropicMessages(messages) {
376
396
  }
377
397
  if (m.role === 'tool') {
378
398
  const last = result[result.length - 1];
379
- // Do not replay native deferred-loader `tool_reference` blocks from
380
- // prior turns. They are tied to the exact Anthropic deferred-tool
381
- // request that produced them; after /model or provider switches the
382
- // new request may not carry the matching deferred tool surface and
383
- // Anthropic rejects the history as a request validation error.
399
+ const native = m.nativeToolSearch;
400
+ const nativeProvider = String(native?.provider || '').toLowerCase();
401
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
402
+ const references = (!nativeProvider || anthropicNative.has(nativeProvider))
403
+ && Array.isArray(native?.toolReferences)
404
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
405
+ : [];
384
406
  const block = {
385
407
  type: 'tool_result',
386
408
  tool_use_id: m.toolCallId || '',
387
- content: normalizeContentForAnthropic(m.content),
409
+ content: references.length
410
+ ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
411
+ : normalizeContentForAnthropic(m.content),
388
412
  };
389
413
  if (last?.role === 'user' && Array.isArray(last.content)) {
390
414
  last.content.push(block);
@@ -620,7 +644,7 @@ export class AnthropicProvider {
620
644
  if (tools?.length || nativeTools.length) {
621
645
  // No cache_control on tools — the system BP covers tools via
622
646
  // Anthropic prefix semantics (order: tools → system → messages).
623
- params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
647
+ params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], chatMsgs, opts)])];
624
648
  }
625
649
  // tool_choice only when tools are actually present (Anthropic rejects
626
650
  // tool_choice without tools). 'none' rides the hard-cap final turn to
@@ -47,3 +47,31 @@ export function customToolCallFromResponseItem(item) {
47
47
  export function isCustomToolCallRecord(call) {
48
48
  return call?.nativeType === 'custom_tool_call';
49
49
  }
50
+
51
+ export function nativeToolSearchCallInput(call) {
52
+ if (call?.nativeType !== 'tool_search_call') return null;
53
+ return {
54
+ type: 'tool_search_call',
55
+ call_id: call.id || '',
56
+ execution: 'client',
57
+ arguments: call.arguments && typeof call.arguments === 'object' ? call.arguments : {},
58
+ };
59
+ }
60
+
61
+ export function nativeToolSearchOutputInput(message, provider) {
62
+ const native = message?.nativeToolSearch;
63
+ const source = String(native?.provider || '').toLowerCase();
64
+ const target = String(provider || '').toLowerCase();
65
+ const openaiNative = new Set(['openai', 'openai-oauth']);
66
+ const sameNativeFamily = source === target
67
+ || (openaiNative.has(source) && openaiNative.has(target));
68
+ if (!native || (source && !sameNativeFamily)) return null;
69
+ if (!Array.isArray(native.openaiTools)) return null;
70
+ return {
71
+ type: 'tool_search_output',
72
+ call_id: message.toolCallId || '',
73
+ status: 'completed',
74
+ execution: 'client',
75
+ tools: native.openaiTools,
76
+ };
77
+ }
@@ -117,7 +117,7 @@ function functionToolFromSessionTool(t, name = t?.name) {
117
117
  export function toResponsesTools(tools, options = {}) {
118
118
  const provider = String(options?.provider || '').toLowerCase();
119
119
  const allowNativeToolSearch = options?.nativeToolSearch === true
120
- || (options?.nativeToolSearch !== false && provider !== 'xai');
120
+ || (options?.nativeToolSearch !== false && (provider === 'openai' || provider === 'openai-oauth'));
121
121
  return tools.map((t) => {
122
122
  // load_tool advertises as the OpenAI-native `tool_search` wire type
123
123
  // (legacy 'tool_search' name still accepted for back-compat). xAI/Grok