mixdog 0.9.49 → 0.9.51

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 (39) hide show
  1. package/README.md +6 -6
  2. package/package.json +7 -7
  3. package/scripts/embedding-worker-exit-test.mjs +76 -0
  4. package/scripts/hook-bus-test.mjs +23 -0
  5. package/scripts/internal-tools-normalization-test.mjs +10 -0
  6. package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
  7. package/scripts/provider-toolcall-test.mjs +200 -2
  8. package/scripts/session-bench-cache-break-test.mjs +102 -0
  9. package/scripts/session-bench.mjs +101 -42
  10. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  11. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  12. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  13. package/scripts/smoke-loop.mjs +4 -3
  14. package/scripts/smoke.mjs +5 -106
  15. package/scripts/tool-failures.mjs +15 -1
  16. package/scripts/tui-transcript-perf-test.mjs +38 -0
  17. package/scripts/verify-release-assets-test.mjs +15 -381
  18. package/scripts/web-fetch-routing-test.mjs +158 -0
  19. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  20. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
  25. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
  29. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  30. package/src/runtime/search/index.mjs +41 -0
  31. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  32. package/src/runtime/search/tool-defs.mjs +23 -0
  33. package/src/session-runtime/runtime-core.mjs +37 -15
  34. package/src/standalone/hook-bus/handlers.mjs +4 -0
  35. package/src/standalone/hook-bus/rules.mjs +7 -1
  36. package/src/standalone/hook-bus.mjs +10 -2
  37. package/src/tui/App.jsx +17 -11
  38. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  39. package/src/tui/dist/index.mjs +28 -3
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
@@ -59,303 +59,6 @@ function graphFixture() {
59
59
  };
60
60
  }
61
61
 
62
- function yamlScalar(value) {
63
- const trimmed = value.trim();
64
- if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
65
- return trimmed.slice(1, -1).replaceAll("''", "'");
66
- }
67
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
68
- return JSON.parse(trimmed);
69
- }
70
- return trimmed.replace(/\s+#.*$/, '').trim();
71
- }
72
-
73
- function yamlBlockEnd(lines, start, indent) {
74
- for (let index = start + 1; index < lines.length; index += 1) {
75
- const text = lines[index];
76
- if (/^\s*(?:#.*)?$/.test(text)) continue;
77
- if (text.match(/^\s*/)[0].length <= indent) return index;
78
- }
79
- return lines.length;
80
- }
81
-
82
- function yamlMappingEntry(text, indent) {
83
- if (text.match(/^\s*/)[0].length !== indent) return undefined;
84
- const match = text.slice(indent).match(/^("(?:\\.|[^"])*"|'(?:''|[^'])*'|[A-Za-z0-9_-]+):\s*(.*)$/);
85
- if (!match) return undefined;
86
- return { key: yamlScalar(match[1]), rawValue: match[2] };
87
- }
88
-
89
- function yamlChildren(lines, start, end, parentIndent) {
90
- let childIndent;
91
- const children = [];
92
- for (let index = start + 1; index < end; index += 1) {
93
- const text = lines[index];
94
- if (/^\s*(?:#.*)?$/.test(text)) continue;
95
- const indent = text.match(/^\s*/)[0].length;
96
- if (indent <= parentIndent) break;
97
- childIndent ??= indent;
98
- if (indent !== childIndent) continue;
99
- const entry = yamlMappingEntry(text, indent);
100
- if (entry) children.push({
101
- ...entry,
102
- index,
103
- indent,
104
- end: yamlBlockEnd(lines, index, indent),
105
- });
106
- }
107
- return children;
108
- }
109
-
110
- function yamlEnvironment(lines, parent) {
111
- const env = {};
112
- for (const entry of yamlChildren(lines, parent.index, parent.end, parent.indent)) {
113
- env[entry.key] = yamlScalar(entry.rawValue);
114
- }
115
- return env;
116
- }
117
-
118
- function yamlSteps(lines, parent) {
119
- const itemIndexes = [];
120
- let itemIndent;
121
- for (let index = parent.index + 1; index < parent.end; index += 1) {
122
- const match = lines[index].match(/^(\s*)-\s+(.*)$/);
123
- if (!match || /^\s*(?:#.*)?$/.test(lines[index])) continue;
124
- const indent = match[1].length;
125
- if (itemIndent === undefined) itemIndent = indent;
126
- if (indent === itemIndent) itemIndexes.push(index);
127
- }
128
-
129
- return itemIndexes.map((start, position) => {
130
- const end = itemIndexes[position + 1] ?? parent.end;
131
- const fieldIndent = itemIndent + 2;
132
- const fields = { env: {} };
133
- for (let index = start; index < end; index += 1) {
134
- const source = index === start
135
- ? lines[index].replace(/^\s*-\s+/, '')
136
- : lines[index].slice(fieldIndent);
137
- if (index !== start && lines[index].match(/^\s*/)[0].length !== fieldIndent) continue;
138
- const field = source.match(/^(name|run|uses|env):\s*(.*)$/);
139
- if (!field) continue;
140
- const [, key, rawValue] = field;
141
- if (key === 'env' && /^(?:#.*)?$/.test(rawValue)) {
142
- fields.env = yamlEnvironment(lines, {
143
- index,
144
- indent: fieldIndent,
145
- end: yamlBlockEnd(lines, index, fieldIndent),
146
- });
147
- } else if (key === 'run' && /^[|>][-+0-9]*\s*(?:#.*)?$/.test(rawValue)) {
148
- const commands = [];
149
- for (let blockIndex = index + 1; blockIndex < end; blockIndex += 1) {
150
- const blockLine = lines[blockIndex];
151
- if (blockLine.trim() === '' || /^\s*#/.test(blockLine)) continue;
152
- if (blockLine.match(/^\s*/)[0].length <= fieldIndent) break;
153
- commands.push(blockLine.trim());
154
- }
155
- fields.run = commands.join('\n');
156
- } else {
157
- fields[key] = yamlScalar(rawValue);
158
- }
159
- }
160
- return fields;
161
- });
162
- }
163
-
164
- function parseWorkflowSubset(workflow) {
165
- const lines = workflow.replaceAll('\r\n', '\n').split('\n');
166
- const roots = lines
167
- .map((line, index) => ({ line, index }))
168
- .filter(({ line }) => yamlMappingEntry(line, 0)?.key === 'jobs');
169
- assert.equal(roots.length, 1, 'workflow must define exactly one jobs mapping');
170
- const jobsBlock = {
171
- index: roots[0].index,
172
- indent: 0,
173
- end: yamlBlockEnd(lines, roots[0].index, 0),
174
- };
175
- const jobs = yamlChildren(lines, jobsBlock.index, jobsBlock.end, jobsBlock.indent).map((entry) => {
176
- const children = yamlChildren(lines, entry.index, entry.end, entry.indent);
177
- const envEntry = children.find((child) => child.key === 'env');
178
- const stepsEntry = children.find((child) => child.key === 'steps');
179
- const strategyEntry = children.find((child) => child.key === 'strategy');
180
- const matrixEntry = strategyEntry
181
- ? yamlChildren(lines, strategyEntry.index, strategyEntry.end, strategyEntry.indent)
182
- .find((child) => child.key === 'matrix')
183
- : undefined;
184
- const matrixLines = matrixEntry
185
- ? lines.slice(matrixEntry.index + 1, matrixEntry.end)
186
- .filter((line) => !/^\s*(?:#.*)?$/.test(line))
187
- .map((line) => line.trim())
188
- : [];
189
- const includeEntry = matrixEntry
190
- ? yamlChildren(lines, matrixEntry.index, matrixEntry.end, matrixEntry.indent)
191
- .find((child) => child.key === 'include')
192
- : undefined;
193
- const matrixInclude = includeEntry
194
- ? lines.slice(includeEntry.index + 1, includeEntry.end)
195
- .filter((line) => /^\s*-\s+/.test(line) && !/^\s*-\s*#/.test(line))
196
- .map((line) => line.replace(/^\s*-\s+/, '').trim())
197
- : [];
198
- return {
199
- name: entry.key,
200
- env: envEntry ? yamlEnvironment(lines, envEntry) : {},
201
- matrix: matrixEntry ? { lines: matrixLines, include: matrixInclude } : undefined,
202
- steps: stepsEntry ? yamlSteps(lines, stepsEntry) : [],
203
- };
204
- });
205
- return { jobs };
206
- }
207
-
208
- function workflowJob(workflow, name) {
209
- const matches = parseWorkflowSubset(workflow).jobs.filter((job) => job.name === name);
210
- assert.equal(matches.length, 1, `workflow must define exactly one jobs.${name}`);
211
- return matches[0];
212
- }
213
-
214
- function namedStep(job, name) {
215
- const matches = job.steps.filter((step) => step.name === name);
216
- assert.equal(matches.length, 1, `jobs.${job.name} must contain exactly one "${name}" step`);
217
- return matches[0];
218
- }
219
-
220
- function extractReleaseSteps(workflow) {
221
- return workflowJob(workflow, 'release').steps;
222
- }
223
-
224
- function runLines(step) {
225
- return step.run?.split('\n') ?? [];
226
- }
227
-
228
- function assertRunLine(step, line, message) {
229
- assert.ok(runLines(step).includes(line), message);
230
- }
231
-
232
- function matrixRowsWithKey(matrix, key) {
233
- return matrix.include.filter((row) => {
234
- const fields = row.match(/^\{(.*)\}$/)?.[1] ?? row;
235
- return new RegExp(`(?:^|,)\\s*${key}\\s*:`).test(fields);
236
- });
237
- }
238
-
239
- function effectiveStepEnv(job, step, key) {
240
- if (Object.hasOwn(step.env, key)) return step.env[key];
241
- return job.env[key];
242
- }
243
-
244
- function cargoBuildCommands(step) {
245
- return runLines(step).filter((line) => /\bcargo\s+build(?:\s|$)/.test(line));
246
- }
247
-
248
- function assertRepeatedCommand(commands, expected, count, message) {
249
- assert.equal(commands.length, count, message);
250
- for (const command of commands) {
251
- assert.equal(command, expected, message);
252
- }
253
- }
254
-
255
- function assertProvenanceDigestLinkage(step) {
256
- for (const line of [
257
- 'FIRST=$(sha256 "_asset/$ASSET")',
258
- 'SECOND=$(sha256 "$BIN")',
259
- 'cmp "_asset/$ASSET" "$BIN"',
260
- 'test "$FIRST" = "$SECOND"',
261
- 'ASSET_DIGEST=$FIRST \\',
262
- 'assetDigest: process.env.ASSET_DIGEST,',
263
- ]) {
264
- assertRunLine(step, line, `reproducibility step must preserve digest/provenance linkage: ${line}`);
265
- }
266
- }
267
-
268
- function assertMatrixRustflags(job) {
269
- assert.ok(job.matrix, 'jobs.build must define a strategy matrix');
270
- assert.ok(job.matrix.include.length > 0, 'jobs.build matrix must define include rows');
271
- assert.equal(
272
- matrixRowsWithKey(job.matrix, 'rustflags').length,
273
- job.matrix.include.length,
274
- 'every jobs.build matrix include row must define rustflags',
275
- );
276
- assert.equal(
277
- job.env.RUSTFLAGS,
278
- '${{ matrix.rustflags }}',
279
- 'jobs.build must set RUSTFLAGS from matrix.rustflags',
280
- );
281
- }
282
-
283
- function assertProtocolStep(job) {
284
- const protocol = namedStep(job, 'Test protocol');
285
- assert.equal(
286
- protocol.env.RUSTFLAGS,
287
- '',
288
- 'protocol tests must explicitly clear RUSTFLAGS for loadable host proc-macro dylibs',
289
- );
290
- assertRunLine(
291
- protocol,
292
- 'cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml',
293
- 'Test protocol step must own the locked graph protocol test command',
294
- );
295
- }
296
-
297
- function assertReproducibilityStep(job) {
298
- const reproducibility = namedStep(job, 'Build twice and compare');
299
- assert.equal(
300
- effectiveStepEnv(job, reproducibility, 'RUSTFLAGS'),
301
- '${{ matrix.rustflags }}',
302
- 'reproducibility builds must preserve matrix RUSTFLAGS',
303
- );
304
- assertRepeatedCommand(
305
- cargoBuildCommands(reproducibility),
306
- 'cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
307
- 2,
308
- 'Build twice and compare must own exactly two targeted locked release cargo build commands',
309
- );
310
- assertProvenanceDigestLinkage(reproducibility);
311
- }
312
-
313
- function assertGraphReleaseRustflagsContract(workflow) {
314
- const build = workflowJob(workflow, 'build');
315
- assertMatrixRustflags(build);
316
- assertProtocolStep(build);
317
- assertReproducibilityStep(build);
318
- }
319
-
320
- function assertReleaseWorkflowOrdering(workflow) {
321
- const steps = extractReleaseSteps(workflow);
322
- const required = [
323
- ['Verify bundled release assets', 'node scripts/verify-release-assets.mjs'],
324
- ['Install', 'npm ci'],
325
- ['Execute code graph from a clean cache', 'npm run test:code-graph-clean-cache'],
326
- ['Focused release regressions', 'npm run test:release-focused'],
327
- ['Smoke', 'npm run smoke'],
328
- ['Publish', 'npm publish --provenance --access public'],
329
- ];
330
- const indexes = {};
331
- for (const [name, command] of required) {
332
- const matches = steps
333
- .map((step, index) => ({ step, index }))
334
- .filter(({ step }) => step.name === name);
335
- assert.equal(matches.length, 1, `release workflow must contain exactly one "${name}" step`);
336
- assert.ok(
337
- matches[0].step.run?.split('\n').includes(command),
338
- `"${name}" step must run ${command}`,
339
- );
340
- indexes[name] = matches[0].index;
341
- }
342
-
343
- const asset = indexes['Verify bundled release assets'];
344
- const install = indexes.Install;
345
- const cleanCache = indexes['Execute code graph from a clean cache'];
346
- const focused = indexes['Focused release regressions'];
347
- const smoke = indexes.Smoke;
348
- const publish = indexes.Publish;
349
- assert.ok(asset < install, 'release asset blocker must run before Install');
350
- assert.ok(asset < cleanCache, 'release asset blocker must run before clean-cache graph execution');
351
- assert.ok(install < cleanCache, 'Install must run before clean-cache graph execution');
352
- assert.ok(cleanCache < focused, 'clean-cache graph execution must run before Focused release regressions');
353
- assert.ok(focused < smoke, 'Focused release regressions must run before Smoke');
354
- assert.ok(smoke < publish, 'Smoke must run before Publish');
355
- assert.ok(asset < publish, 'release asset blocker must run before Publish');
356
- assert.ok(cleanCache < publish, 'clean-cache graph execution must run before Publish');
357
- }
358
-
359
62
  test('accepts independent strict patch, runtime, app, and graph versions', () => {
360
63
  assert.equal(validatePatchManifest(patchFixture(), `[package]\nversion = "${VERSION}"\n`).version, VERSION);
361
64
  assert.equal(validateRuntimeManifest(runtimeFixture()).release_tag, 'runtime-v1.2.3');
@@ -534,6 +237,13 @@ test('cancels immediately when a stream exceeds its declared size below the abso
534
237
 
535
238
  test('full guard reads deterministic fixtures and downloads every declared asset', async () => {
536
239
  const dir = await mkdtemp(join(tmpdir(), 'mixdog-release-assets-'));
240
+ const patch = patchFixture();
241
+ const runtime = runtimeFixture();
242
+ const graph = graphFixture();
243
+ const expectedUrls = new Set(
244
+ [...Object.values(patch.assets), ...Object.values(runtime.assets), ...Object.values(graph.assets)]
245
+ .map(({ url }) => url),
246
+ );
537
247
  const paths = {
538
248
  patchManifestPath: join(dir, 'patch.json'),
539
249
  cargoPath: join(dir, 'Cargo.toml'),
@@ -542,21 +252,23 @@ test('full guard reads deterministic fixtures and downloads every declared asset
542
252
  packagePath: join(dir, 'package.json'),
543
253
  };
544
254
  await Promise.all([
545
- writeFile(paths.patchManifestPath, JSON.stringify(patchFixture())),
255
+ writeFile(paths.patchManifestPath, JSON.stringify(patch)),
546
256
  writeFile(paths.cargoPath, `[package]\nversion = "${VERSION}"\n`),
547
- writeFile(paths.runtimeManifestPath, JSON.stringify(runtimeFixture())),
548
- writeFile(paths.graphManifestPath, JSON.stringify(graphFixture())),
257
+ writeFile(paths.runtimeManifestPath, JSON.stringify(runtime)),
258
+ writeFile(paths.graphManifestPath, JSON.stringify(graph)),
549
259
  writeFile(paths.packagePath, JSON.stringify({ version: APP_VERSION })),
550
260
  ]);
551
261
  let downloads = 0;
262
+ const requestedUrls = [];
552
263
  try {
553
264
  await verifyReleaseAssets({
554
265
  ...paths,
555
266
  downloadOptions: {
556
267
  attempts: 1,
557
268
  timeoutMs: 1000,
558
- fetchImpl: async () => {
269
+ fetchImpl: async (url) => {
559
270
  downloads += 1;
271
+ requestedUrls.push(url);
560
272
  return new Response(bytes);
561
273
  },
562
274
  },
@@ -565,83 +277,5 @@ test('full guard reads deterministic fixtures and downloads every declared asset
565
277
  await rm(dir, { recursive: true, force: true });
566
278
  }
567
279
  assert.equal(downloads, 15);
568
- });
569
-
570
- test('release workflow orders the asset and clean-cache blockers before consumers', async () => {
571
- const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
572
- assertReleaseWorkflowOrdering(workflow);
573
-
574
- const quotedNames = workflow
575
- .replace('- name: Install', '- name: "Install"')
576
- .replace('- name: Smoke', "- name: 'Smoke'");
577
- assertReleaseWorkflowOrdering(quotedNames);
578
-
579
- const wrongStep = workflow
580
- .replace('run: npm run smoke', 'run: echo smoke')
581
- .replace('run: npm run test:release-focused', 'run: npm run test:release-focused\n # npm run smoke');
582
- assert.throws(() => assertReleaseWorkflowOrdering(wrongStep), /"Smoke" step must run npm run smoke/);
583
-
584
- const otherJob = workflow
585
- .replace('run: npm run smoke', 'run: echo smoke')
586
- .replace(
587
- '\n release:',
588
- '\n decoy:\n steps:\n - name: Smoke\n run: npm run smoke\n release:',
589
- );
590
- assert.throws(() => assertReleaseWorkflowOrdering(otherJob), /"Smoke" step must run npm run smoke/);
591
- });
592
-
593
- test('graph release clears test RUSTFLAGS but keeps targeted reproducibility builds', async () => {
594
- const workflow = await readFile(new URL('../.github/workflows/graph-release.yml', import.meta.url), 'utf8');
595
- assertGraphReleaseRustflagsContract(workflow);
596
-
597
- const quotedNames = workflow
598
- .replace('- name: Test protocol', '- name: "Test protocol"')
599
- .replace('- name: Build twice and compare', "- name: 'Build twice and compare'");
600
- assertGraphReleaseRustflagsContract(quotedNames);
601
-
602
- assert.throws(
603
- () => assertGraphReleaseRustflagsContract(workflow.replace(' RUSTFLAGS: ""', ' RUSTFLAGS: "-C unsafe-for-host"')),
604
- /protocol tests must explicitly clear RUSTFLAGS/,
605
- );
606
- assert.throws(
607
- () => assertGraphReleaseRustflagsContract(workflow.replace('--release --target', '--release')),
608
- /exactly two targeted locked release cargo build commands/,
609
- );
610
-
611
- const renamedBuild = workflow
612
- .replace('\n build:\n', '\n renamed-build:\n')
613
- .replace(
614
- '\n gate:',
615
- '\n decoy-build:\n env:\n RUSTFLAGS: ${{ matrix.rustflags }}\n'
616
- + ' steps:\n - name: Test protocol\n'
617
- + ' run: cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml\n gate:',
618
- );
619
- assert.throws(
620
- () => assertGraphReleaseRustflagsContract(renamedBuild),
621
- /exactly one jobs\.build/,
622
- );
623
-
624
- const commentedBuild = workflow.replace(
625
- ' cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
626
- ' # cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
627
- );
628
- assert.throws(
629
- () => assertGraphReleaseRustflagsContract(commentedBuild),
630
- /exactly two targeted locked release cargo build commands/,
631
- );
632
-
633
- const commandInWrongStep = workflow
634
- .replace(
635
- ' run: cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml',
636
- ' run: echo protocol command moved',
637
- )
638
- .replace(
639
- ' test "$(git rev-parse HEAD)" = "${{ needs.gate.outputs.source_sha }}"',
640
- ' cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml\n'
641
- + ' test "$(git rev-parse HEAD)" = "${{ needs.gate.outputs.source_sha }}"',
642
- );
643
- assert.throws(
644
- () => assertGraphReleaseRustflagsContract(commandInWrongStep),
645
- /Test protocol step must own/,
646
- );
280
+ assert.deepEqual(new Set(requestedUrls), expectedUrls);
647
281
  });
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import http from 'node:http';
5
+ import {
6
+ isLoopbackHttpUrl,
7
+ preDispatchDenyForSession,
8
+ } from '../src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs';
9
+ import { executeTool } from '../src/runtime/agent/orchestrator/session/loop/tool-exec.mjs';
10
+ import {
11
+ getInternalTools,
12
+ setInternalToolsProvider,
13
+ } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
14
+ import { previewSessionTools } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
15
+ import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
16
+ import { dispatchSearchRuntimeTool } from '../src/session-runtime/runtime-core.mjs';
17
+ import {
18
+ fetchLoopbackText,
19
+ fetchPublicImage,
20
+ } from '../src/runtime/search/lib/http-fetch.mjs';
21
+
22
+ test('pre-dispatch keeps hook-facing web_fetch name unchanged', () => {
23
+ const local = { name: 'web_fetch', arguments: { url: 'http://127.0.0.1:4321/status' } };
24
+ const image = { name: 'web_fetch', arguments: { url: 'https://cdn.example.com/a.png?x=1' } };
25
+ const document = { name: 'web_fetch', arguments: { url: 'https://example.com/docs' } };
26
+ assert.equal(preDispatchDenyForSession({}, local), null);
27
+ assert.equal(preDispatchDenyForSession({}, image), null);
28
+ assert.equal(preDispatchDenyForSession({}, document), null);
29
+ assert.equal(local.name, 'web_fetch');
30
+ assert.equal(image.name, 'web_fetch');
31
+ assert.equal(document.name, 'web_fetch');
32
+ assert.equal(isLoopbackHttpUrl('http://localhost:80/'), true);
33
+ assert.equal(isLoopbackHttpUrl('http://192.168.1.2/'), false);
34
+ });
35
+
36
+ test('hidden routed tools remain dispatchable but absent from every model schema', () => {
37
+ setInternalToolsProvider({
38
+ tools: SEARCH_TOOL_DEFS,
39
+ executor: async () => '',
40
+ });
41
+ const registered = getInternalTools().map((tool) => tool.name);
42
+ assert.equal(registered.includes('local_fetch'), true);
43
+ assert.equal(registered.includes('image_fetch'), true);
44
+ for (const spec of ['full', 'mcp', 'readonly', ['tools:mcp']]) {
45
+ const visible = previewSessionTools(spec, []).map((tool) => tool.name);
46
+ assert.equal(visible.includes('local_fetch'), false, `local_fetch leaked for ${JSON.stringify(spec)}`);
47
+ assert.equal(visible.includes('image_fetch'), false, `image_fetch leaked for ${JSON.stringify(spec)}`);
48
+ assert.equal(visible.includes('web_fetch'), true, `web_fetch missing for ${JSON.stringify(spec)}`);
49
+ }
50
+ });
51
+
52
+ test('hook sees public name before routed dispatch and can change routing inputs', async () => {
53
+ const observed = [];
54
+ setInternalToolsProvider({
55
+ tools: SEARCH_TOOL_DEFS,
56
+ executor: async (name, args) => {
57
+ observed.push({ stage: 'dispatch', name, args });
58
+ return { content: [{ type: 'text', text: name }] };
59
+ },
60
+ });
61
+ const result = await executeTool(
62
+ 'web_fetch',
63
+ { url: 'https://example.com/document' },
64
+ process.cwd(),
65
+ 'routing-order-test',
66
+ {
67
+ beforeToolHook: async ({ name, args }) => {
68
+ observed.push({ stage: 'hook', name, args });
69
+ return { action: 'modify', args: { url: 'http://127.0.0.1:4321/status' } };
70
+ },
71
+ },
72
+ { toolCallId: 'routing-order-call' },
73
+ );
74
+ assert.equal(result, 'local_fetch');
75
+ assert.deepEqual(observed.map(({ stage, name }) => ({ stage, name })), [
76
+ { stage: 'hook', name: 'web_fetch' },
77
+ { stage: 'dispatch', name: 'local_fetch' },
78
+ ]);
79
+ });
80
+
81
+ test('cancellation signal propagates across executeTool and runtime search dispatch', async () => {
82
+ const received = [];
83
+ setInternalToolsProvider({
84
+ tools: SEARCH_TOOL_DEFS,
85
+ executor: (name, args, callerCtx) => dispatchSearchRuntimeTool(name, args, callerCtx, {
86
+ getSearchModule: async () => ({
87
+ handleToolCall: async (_name, _args, options) => {
88
+ received.push({ name: _name, signal: options.signal });
89
+ await new Promise((resolve, reject) => {
90
+ if (options.signal.aborted) reject(options.signal.reason);
91
+ else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
92
+ });
93
+ },
94
+ }),
95
+ getCurrentCwd: () => process.cwd(),
96
+ getSession: () => null,
97
+ notifyFnForSession: () => null,
98
+ runNativeWebSearch: async () => null,
99
+ }),
100
+ });
101
+ for (const [index, testCase] of [
102
+ { url: 'https://example.com/document', expectedName: 'web_fetch' },
103
+ { url: 'http://127.0.0.1:4321/status', expectedName: 'local_fetch' },
104
+ ].entries()) {
105
+ const controller = new AbortController();
106
+ const running = executeTool(
107
+ 'web_fetch',
108
+ { url: testCase.url },
109
+ process.cwd(),
110
+ `cancel-test-${index}`,
111
+ {},
112
+ { toolCallId: `cancel-call-${index}`, signal: controller.signal },
113
+ );
114
+ controller.abort(new Error(`cancelled-by-test-${index}`));
115
+ await assert.rejects(running, new RegExp(`cancelled-by-test-${index}`));
116
+ assert.equal(received[index].name, testCase.expectedName);
117
+ assert.equal(received[index].signal, controller.signal);
118
+ }
119
+ });
120
+
121
+ test('local_fetch reads loopback and rejects redirect escape', async (t) => {
122
+ const server = http.createServer((req, res) => {
123
+ if (req.url === '/escape') {
124
+ res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' });
125
+ res.end();
126
+ return;
127
+ }
128
+ res.writeHead(200, { 'content-type': 'text/plain' });
129
+ res.end('local-ok');
130
+ });
131
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
132
+ t.after(() => server.close());
133
+ const { port } = server.address();
134
+ assert.equal(await fetchLoopbackText(`http://127.0.0.1:${port}/ok`), 'local-ok');
135
+ await assert.rejects(fetchLoopbackText(`http://127.0.0.1:${port}/escape`), /non-loopback/);
136
+ await assert.rejects(fetchLoopbackText('http://10.0.0.1/'), /non-loopback/);
137
+ });
138
+
139
+ test('public image fetch is bounded, media-shaped, and blocks private redirect targets', async () => {
140
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
141
+ const okFetch = async () => new Response(png, {
142
+ status: 200,
143
+ headers: { 'content-type': 'image/png', 'content-length': String(png.length) },
144
+ });
145
+ const image = await fetchPublicImage('https://images.example.com/a.png', { fetchImpl: okFetch });
146
+ assert.deepEqual(image, { mimeType: 'image/png', data: png.toString('base64'), bytes: png.length });
147
+
148
+ let calls = 0;
149
+ const redirectFetch = async () => {
150
+ calls++;
151
+ return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data/' } });
152
+ };
153
+ await assert.rejects(
154
+ fetchPublicImage('https://images.example.com/a.png', { fetchImpl: redirectFetch }),
155
+ /private address/,
156
+ );
157
+ assert.equal(calls, 1);
158
+ });
@@ -247,6 +247,7 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
247
247
  function classifyToolFailure(resultText, toolName) {
248
248
  const raw = String(resultText ?? '');
249
249
  const text = raw.toLowerCase();
250
+ if (isExpectedToolCancellation(raw)) return 'expected-cancellation';
250
251
  // Shell renderers put the machine-readable status on the leading line.
251
252
  // Only inspect that marker: command text and stderr frequently contain
252
253
  // words such as "timeout" or "aborted" and must not rewrite a real exit
@@ -273,11 +274,22 @@ function classifyToolFailure(resultText, toolName) {
273
274
  return 'runtime/failure';
274
275
  }
275
276
 
277
+ function isExpectedToolCancellation(resultText) {
278
+ const leading = String(resultText ?? '').split(/\r?\n/)
279
+ .map((line) => line.trim())
280
+ .find((line) => line && !line.startsWith('⚠️ '))
281
+ ?.replace(/^Error:\s*/i, '') || '';
282
+ return /^Session\s+"[^"]+"\s+closed:\s*(?:aborted|closed)\s+during call\b/i.test(leading);
283
+ }
284
+
276
285
  function traceAgentToolFailure({ sessionId, iteration, toolName, toolKind, toolMs, toolArgs, agent, model, cwd, resultText, resultKind = 'error' }) {
277
286
  if (process.env.MIXDOG_AGENT_TRACE_DISABLE === '1') return;
278
287
  if (!_resolveToolFailurePath()) return;
279
288
  try {
280
289
  const cleanText = _redactLogText(String(resultText ?? ''));
290
+ // A session close is deliberate orchestration, not a tool failure.
291
+ // traceAgentTool still records the error/category on the normal trace.
292
+ if (isExpectedToolCancellation(cleanText)) return;
281
293
  const row = {
282
294
  ts: Date.now(),
283
295
  session_id: normalizeSessionId(sessionId),
@@ -69,6 +69,8 @@ function _normalize(result) {
69
69
  });
70
70
  }
71
71
  if (result && typeof result === 'object' && Array.isArray(result.content)) {
72
+ const hasStructuredMedia = result.content.some((part) => part && typeof part === 'object' && part.type !== 'text');
73
+ if (hasStructuredMedia && result.isError !== true) return result;
72
74
  const text = result.content
73
75
  .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
74
76
  .join('\n');