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.
- package/README.md +6 -6
- package/package.json +7 -7
- package/scripts/embedding-worker-exit-test.mjs +76 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
- package/scripts/provider-toolcall-test.mjs +200 -2
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +4 -3
- package/scripts/smoke.mjs +5 -106
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/tui-transcript-perf-test.mjs +38 -0
- package/scripts/verify-release-assets-test.mjs +15 -381
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/session-runtime/runtime-core.mjs +37 -15
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- 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,
|
|
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(
|
|
255
|
+
writeFile(paths.patchManifestPath, JSON.stringify(patch)),
|
|
546
256
|
writeFile(paths.cargoPath, `[package]\nversion = "${VERSION}"\n`),
|
|
547
|
-
writeFile(paths.runtimeManifestPath, JSON.stringify(
|
|
548
|
-
writeFile(paths.graphManifestPath, JSON.stringify(
|
|
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');
|