mixdog 0.9.50 → 0.9.52
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/package.json +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- 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/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- 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/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- 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/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- 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/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- 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 +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -5,6 +5,11 @@ import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/en
|
|
|
5
5
|
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
6
6
|
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
7
7
|
import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
|
|
8
|
+
import {
|
|
9
|
+
isCompletedTranscriptTail,
|
|
10
|
+
isCompletedTranscriptTailAppendedThisCommit,
|
|
11
|
+
isLiveSpinnerMetaVisible,
|
|
12
|
+
} from '../src/tui/app/live-spinner-visibility.mjs';
|
|
8
13
|
|
|
9
14
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
15
|
|
|
@@ -91,6 +96,7 @@ function makeTurnHarness(ask, stateOverrides = {}, bagOverrides = {}) {
|
|
|
91
96
|
|
|
92
97
|
test('successful mid-turn compact trims history but preserves live turn references', async () => {
|
|
93
98
|
let preservedTail = false;
|
|
99
|
+
let compactKeepsSpinnerVisible = false;
|
|
94
100
|
let contextSyncs = 0;
|
|
95
101
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
96
102
|
options.onTextDelta('before compact\n');
|
|
@@ -98,6 +104,8 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
98
104
|
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
99
105
|
assert.equal(contextSyncs, 1, 'compact event must refresh context before returning');
|
|
100
106
|
preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
|
|
107
|
+
compactKeepsSpinnerVisible = harness.getState().spinner?.active === true
|
|
108
|
+
&& harness.getState().items.at(-1)?.kind === 'statusdone';
|
|
101
109
|
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
102
110
|
assert.equal(contextSyncs, 2, 'each compact event refreshes context immediately');
|
|
103
111
|
options.onTextDelta('after compact\n');
|
|
@@ -113,6 +121,7 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
113
121
|
|
|
114
122
|
assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
|
|
115
123
|
assert.equal(preservedTail, true);
|
|
124
|
+
assert.equal(compactKeepsSpinnerVisible, true, 'a compact status leaves the active turn spinner in place');
|
|
116
125
|
assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
|
|
117
126
|
assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
|
|
118
127
|
assert.equal(
|
|
@@ -125,6 +134,35 @@ test('successful mid-turn compact trims history but preserves live turn referenc
|
|
|
125
134
|
);
|
|
126
135
|
});
|
|
127
136
|
|
|
137
|
+
test('live spinner remains visible across compact status and follows turn teardown', () => {
|
|
138
|
+
const activeTurnSpinner = { active: true };
|
|
139
|
+
const visible = (liveSpinner, liveSpinnerIsCommand, kind) => isLiveSpinnerMetaVisible({
|
|
140
|
+
inputBoxHidden: false,
|
|
141
|
+
slashPaletteOpen: false,
|
|
142
|
+
liveSpinner,
|
|
143
|
+
liveSpinnerIsCommand,
|
|
144
|
+
latestTranscriptItem: kind ? { kind } : null,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.equal(visible(activeTurnSpinner, false, 'statusdone'), true);
|
|
148
|
+
assert.equal(visible(activeTurnSpinner, false, 'turndone'), false);
|
|
149
|
+
assert.equal(visible(null, false, 'turndone'), false);
|
|
150
|
+
assert.equal(visible({ active: true }, true, 'turndone'), true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('completed transcript tail masking requires a new done tail', () => {
|
|
154
|
+
assert.equal(isCompletedTranscriptTail({ id: 'turn', kind: 'turndone' }), true);
|
|
155
|
+
assert.equal(isCompletedTranscriptTail({ id: 'status', kind: 'statusdone' }), true);
|
|
156
|
+
assert.equal(isCompletedTranscriptTail({ id: 'assistant', kind: 'assistant' }), false);
|
|
157
|
+
assert.equal(isCompletedTranscriptTail({ id: 'user', kind: 'user' }), false);
|
|
158
|
+
assert.equal(isCompletedTranscriptTail(null), false);
|
|
159
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'turn', kind: 'turndone' }, 'turn'), false);
|
|
160
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'status', kind: 'statusdone' }, 'status'), false);
|
|
161
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next', kind: 'turndone' }, 'previous'), true);
|
|
162
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'next-status', kind: 'statusdone' }, 'previous'), true);
|
|
163
|
+
assert.equal(isCompletedTranscriptTailAppendedThisCommit({ id: 'assistant', kind: 'assistant' }, 'previous'), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
128
166
|
test('failed mid-turn compact leaves prior transcript items untouched', async () => {
|
|
129
167
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
130
168
|
options.onCompactEvent({ status: 'failed' });
|
|
@@ -175,8 +213,7 @@ test('abort after stream progress settles the tail and leaves no orphan', async
|
|
|
175
213
|
assert.match(assistants[0].text, /partial line/);
|
|
176
214
|
});
|
|
177
215
|
|
|
178
|
-
|
|
179
|
-
test('abort before a newline has no tail id and preserves prior text-drop parity', async () => {
|
|
216
|
+
test('abort before a newline settles the accumulated partial response', async () => {
|
|
180
217
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
181
218
|
options.onTextDelta('partial without newline');
|
|
182
219
|
await wait(30);
|
|
@@ -187,11 +224,10 @@ test('abort before a newline has no tail id and preserves prior text-drop parity
|
|
|
187
224
|
|
|
188
225
|
assert.equal(await harness.runTurn('go'), 'cancelled');
|
|
189
226
|
assert.equal(harness.getState().streamingTail, null);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
);
|
|
227
|
+
const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
|
|
228
|
+
assert.equal(assistants.length, 1);
|
|
229
|
+
assert.equal(assistants[0].streaming, false);
|
|
230
|
+
assert.equal(assistants[0].text, 'partial without newline');
|
|
195
231
|
});
|
|
196
232
|
|
|
197
233
|
// Codifies pre-existing parity: the old in-items completed-line snapshot survived starved recovery.
|
|
@@ -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
|
+
});
|
package/src/cli.mjs
CHANGED
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { classifyCliInvocation } from './headless-command.mjs';
|
|
5
|
+
import {
|
|
6
|
+
beginProcessLifecycle,
|
|
7
|
+
finishProcessLifecycle,
|
|
8
|
+
} from './runtime/shared/process-lifecycle.mjs';
|
|
9
|
+
import { stagedChildExitCode } from './runtime/shared/staged-child-result.mjs';
|
|
5
10
|
|
|
6
11
|
const argv = process.argv.slice(2);
|
|
7
12
|
|
|
8
13
|
let swapped = false;
|
|
9
14
|
const invocation = classifyCliInvocation(argv);
|
|
10
15
|
const skipHostPrelude = invocation.kind === 'headless' || invocation.skipHostPrelude === true;
|
|
16
|
+
if (!skipHostPrelude) {
|
|
17
|
+
beginProcessLifecycle({ safeCommandLine: argv.length === 0 });
|
|
18
|
+
}
|
|
11
19
|
if (!skipHostPrelude) {
|
|
12
20
|
// Interactive/general sessions retain the staged-update and live-session
|
|
13
21
|
// semantics. Headless role commands skip both because those helpers touch the
|
|
@@ -37,7 +45,9 @@ async function main() {
|
|
|
37
45
|
env: { ...process.env, MIXDOG_SWAP_REEXEC: '1' },
|
|
38
46
|
windowsHide: true,
|
|
39
47
|
});
|
|
40
|
-
if (!r.error)
|
|
48
|
+
if (!r.error) {
|
|
49
|
+
return stagedChildExitCode(r);
|
|
50
|
+
}
|
|
41
51
|
} catch { /* fall through to in-place run */ }
|
|
42
52
|
}
|
|
43
53
|
const { run } = await import('./app.mjs');
|
|
@@ -45,8 +55,11 @@ async function main() {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
main().then((code) => {
|
|
48
|
-
|
|
58
|
+
const exitCode = Number.isInteger(code) ? code : 0;
|
|
59
|
+
finishProcessLifecycle('clean-shutdown', exitCode);
|
|
60
|
+
process.exit(exitCode);
|
|
49
61
|
}).catch((error) => {
|
|
50
62
|
process.stderr.write(`${error?.stack || error?.message || String(error)}\n`);
|
|
63
|
+
finishProcessLifecycle('catchable-fatal-error', 1);
|
|
51
64
|
process.exit(1);
|
|
52
65
|
});
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
watchdogPartialHandoffFromError,
|
|
42
42
|
AgentStallAbortError,
|
|
43
43
|
} from './agent-progress-watchdog.mjs';
|
|
44
|
+
import { resourceAdmission } from '../../../shared/resource-admission.mjs';
|
|
44
45
|
|
|
45
46
|
// Cap agent role synthesis to ~3000 tokens (~12 KB at the 4 B/tok
|
|
46
47
|
// working average). Pool B explore/recall/search answers occasionally land
|
|
@@ -300,7 +301,27 @@ export function makeAgentDispatch(opts = {}) {
|
|
|
300
301
|
if (typeof prompt !== 'string' || !prompt) {
|
|
301
302
|
throw new Error(`[agent-dispatch] prompt required for agent "${agent}"`);
|
|
302
303
|
}
|
|
304
|
+
const admission = opts.resourceAdmission || resourceAdmission;
|
|
305
|
+
const admissionAbortLink = composeAgentDispatchAbortSignal([
|
|
306
|
+
opts.parentSignal,
|
|
307
|
+
callParentSignal,
|
|
308
|
+
]);
|
|
309
|
+
let lease;
|
|
310
|
+
try {
|
|
311
|
+
lease = await admission.acquire('agent', {
|
|
312
|
+
signal: admissionAbortLink.signal,
|
|
313
|
+
label: agent,
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
admissionAbortLink.dispose();
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
303
320
|
|
|
321
|
+
const runAdmitted = (task) => typeof admission.runWithLease === 'function'
|
|
322
|
+
? admission.runWithLease(lease, task)
|
|
323
|
+
: task();
|
|
324
|
+
return await runAdmitted(async () => {
|
|
304
325
|
const config = opts.config || loadConfig({ secrets: false });
|
|
305
326
|
const routeOrName = resolveMaintenanceRoute({
|
|
306
327
|
preset: presetArg,
|
|
@@ -535,5 +556,10 @@ export function makeAgentDispatch(opts = {}) {
|
|
|
535
556
|
// try/catch falls through in unexpected ways.
|
|
536
557
|
try { await updateSessionStatus(session.id, terminalStatus); } catch { /* ignore */ }
|
|
537
558
|
}
|
|
559
|
+
});
|
|
560
|
+
} finally {
|
|
561
|
+
await lease.release();
|
|
562
|
+
admissionAbortLink.dispose();
|
|
563
|
+
}
|
|
538
564
|
};
|
|
539
565
|
}
|
|
@@ -259,7 +259,10 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
|
|
|
259
259
|
export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
260
260
|
if (!snapshot || !policy) return null;
|
|
261
261
|
|
|
262
|
-
const startedAt = snapshot.modelRequestStartedAt ||
|
|
262
|
+
const startedAt = snapshot.modelRequestStartedAt || 0;
|
|
263
|
+
// stage=connecting with no request timestamp means the provider request is
|
|
264
|
+
// still in the admission queue. Queue wait is outside every watchdog.
|
|
265
|
+
if (!startedAt && snapshot.stage === 'connecting') return null;
|
|
263
266
|
const firstTransportMs = policy.firstTransportMs ?? policy.firstResponseMs ?? 0;
|
|
264
267
|
const firstSemanticMs = policy.firstSemanticMs ?? policy.firstVisibleCeilingMs ?? 0;
|
|
265
268
|
// Independent fixed deadlines from request start. Transport can satisfy
|
|
@@ -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');
|