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
|
@@ -60,6 +60,29 @@ if (command.includes('rm -rf')) {
|
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('standard PreToolUse hook can replace the tool name', async () => {
|
|
64
|
+
const root = tempRoot();
|
|
65
|
+
const hookScript = join(root, 'rename.mjs');
|
|
66
|
+
writeFileSync(hookScript, `console.log(JSON.stringify({ hookSpecificOutput: {
|
|
67
|
+
hookEventName: 'PreToolUse',
|
|
68
|
+
updatedToolName: 'local_fetch'
|
|
69
|
+
}}));\n`, 'utf8');
|
|
70
|
+
const hooksFile = join(root, 'hooks.json');
|
|
71
|
+
writeJson(hooksFile, { hooks: { PreToolUse: [{ matcher: 'web_fetch', hooks: [{ type: 'command', command: process.execPath, args: [hookScript] }] }] } });
|
|
72
|
+
const prev = process.env.MIXDOG_HOOKS_FILE;
|
|
73
|
+
process.env.MIXDOG_HOOKS_FILE = hooksFile;
|
|
74
|
+
try {
|
|
75
|
+
const decision = await createStandaloneHookBus({ dataDir: root }).beforeTool({
|
|
76
|
+
cwd: root, name: 'web_fetch', args: { url: 'http://localhost:3000/' },
|
|
77
|
+
});
|
|
78
|
+
assert.equal(decision.action, 'modify');
|
|
79
|
+
assert.equal(decision.name, 'local_fetch');
|
|
80
|
+
} finally {
|
|
81
|
+
if (prev == null) delete process.env.MIXDOG_HOOKS_FILE;
|
|
82
|
+
else process.env.MIXDOG_HOOKS_FILE = prev;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
63
86
|
test('UserPromptSubmit hook returns additional context', async () => {
|
|
64
87
|
const root = tempRoot();
|
|
65
88
|
const hookScript = join(root, 'prompt.mjs');
|
|
@@ -50,3 +50,13 @@ test('isError:false output stays normal', async () => {
|
|
|
50
50
|
assert.equal(result, 'search complete');
|
|
51
51
|
assert.equal(classifyResultKind(result), 'normal');
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
test('structured image output survives internal tool normalization', async () => {
|
|
55
|
+
const input = {
|
|
56
|
+
content: [
|
|
57
|
+
{ type: 'text', text: 'downloaded' },
|
|
58
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'YWJj' } },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
assert.deepEqual(await normalizeToolResult(input), input);
|
|
62
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
|
|
10
|
+
const DATA_DIR = mkdtempSync(join(tmpdir(), 'mixdog-interrupted-turn-'));
|
|
11
|
+
process.env.MIXDOG_DATA_DIR = DATA_DIR;
|
|
12
|
+
|
|
13
|
+
const PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
14
|
+
const USER_INTERRUPTION_MESSAGE = '[Request interrupted by user]';
|
|
15
|
+
const TOOL_USE_INTERRUPTION_MESSAGE = '[Request interrupted by user for tool use]';
|
|
16
|
+
const STREAMING_INTERRUPTED_TOOL_RESULT = 'Interrupted by user';
|
|
17
|
+
const TOOL_USE_REJECT_RESULT = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
|
|
18
|
+
|
|
19
|
+
function deferred() {
|
|
20
|
+
let resolve;
|
|
21
|
+
const promise = new Promise((done) => { resolve = done; });
|
|
22
|
+
return { promise, resolve };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function waitForAbort(opts) {
|
|
26
|
+
return new Promise((_resolve, reject) => {
|
|
27
|
+
const signal = opts?.signal;
|
|
28
|
+
if (signal?.aborted) {
|
|
29
|
+
reject(signal.reason);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('interrupted turns keep Claude Code-compatible model history boundaries', { concurrency: false }, async (t) => {
|
|
37
|
+
const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
|
|
38
|
+
await initProviders({ gemini: { enabled: true, apiKey: 'test-only' } });
|
|
39
|
+
const provider = getProvider('gemini');
|
|
40
|
+
const {
|
|
41
|
+
abortSessionTurn,
|
|
42
|
+
askSession,
|
|
43
|
+
createSession,
|
|
44
|
+
getSession,
|
|
45
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
|
|
46
|
+
const { saveSessionAsync } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
|
|
47
|
+
|
|
48
|
+
const createTestSession = (tools = []) => createSession({
|
|
49
|
+
provider: 'gemini',
|
|
50
|
+
model: 'gemini-test',
|
|
51
|
+
tools,
|
|
52
|
+
cwd: process.cwd(),
|
|
53
|
+
skipAgentRules: true,
|
|
54
|
+
skipSkills: true,
|
|
55
|
+
compaction: { auto: false },
|
|
56
|
+
});
|
|
57
|
+
const expectInterrupted = async (promise) => {
|
|
58
|
+
await assert.rejects(promise, (error) => error?.name === 'SessionClosedError');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
await t.test('before response: rewinds the provisional user turn', async () => {
|
|
62
|
+
const session = createTestSession();
|
|
63
|
+
const baselineMessages = session.messages.slice();
|
|
64
|
+
const baselineSessionStart = session.sessionStartMetaInjected === true;
|
|
65
|
+
const entered = deferred();
|
|
66
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
67
|
+
entered.resolve();
|
|
68
|
+
return waitForAbort(opts);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const asking = askSession(session.id, 'cancel before response', null, null, process.cwd());
|
|
72
|
+
await entered.promise;
|
|
73
|
+
assert.equal(abortSessionTurn(session.id, 'user-cancel'), true);
|
|
74
|
+
await expectInterrupted(asking);
|
|
75
|
+
|
|
76
|
+
const persisted = getSession(session.id);
|
|
77
|
+
assert.deepEqual(persisted.messages, baselineMessages);
|
|
78
|
+
assert.equal(persisted.sessionStartMetaInjected === true, baselineSessionStart);
|
|
79
|
+
assert.equal(persisted.liveTurnMessages, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t.test('streaming: preserves a partial response without a newline', async () => {
|
|
83
|
+
const session = createTestSession();
|
|
84
|
+
const streamed = deferred();
|
|
85
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
86
|
+
opts.onTextDelta?.('partial without newline');
|
|
87
|
+
streamed.resolve();
|
|
88
|
+
return waitForAbort(opts);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const asking = askSession(
|
|
92
|
+
session.id,
|
|
93
|
+
'stream a partial response',
|
|
94
|
+
null,
|
|
95
|
+
null,
|
|
96
|
+
process.cwd(),
|
|
97
|
+
null,
|
|
98
|
+
{ onTextDelta: () => {} },
|
|
99
|
+
);
|
|
100
|
+
await streamed.promise;
|
|
101
|
+
abortSessionTurn(session.id, 'user-cancel');
|
|
102
|
+
await expectInterrupted(asking);
|
|
103
|
+
|
|
104
|
+
const persisted = getSession(session.id);
|
|
105
|
+
const partial = persisted.messages.find((message) => (
|
|
106
|
+
message.role === 'assistant' && message.content === 'partial without newline'
|
|
107
|
+
));
|
|
108
|
+
assert.deepEqual(partial, {
|
|
109
|
+
role: 'assistant',
|
|
110
|
+
content: 'partial without newline',
|
|
111
|
+
});
|
|
112
|
+
assert.equal(persisted.messages.at(-1)?.content, USER_INTERRUPTION_MESSAGE);
|
|
113
|
+
assert.equal(persisted.liveTurnMessages, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t.test('queued interrupt: preserves progress without a synthetic marker', async () => {
|
|
117
|
+
const session = createTestSession();
|
|
118
|
+
const streamed = deferred();
|
|
119
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
120
|
+
opts.onTextDelta?.('partial before queued redirect');
|
|
121
|
+
streamed.resolve();
|
|
122
|
+
return waitForAbort(opts);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const asking = askSession(
|
|
126
|
+
session.id,
|
|
127
|
+
'active request',
|
|
128
|
+
null,
|
|
129
|
+
null,
|
|
130
|
+
process.cwd(),
|
|
131
|
+
null,
|
|
132
|
+
{ onTextDelta: () => {} },
|
|
133
|
+
);
|
|
134
|
+
await streamed.promise;
|
|
135
|
+
abortSessionTurn(session.id, 'interrupt');
|
|
136
|
+
await expectInterrupted(asking);
|
|
137
|
+
|
|
138
|
+
const persisted = getSession(session.id);
|
|
139
|
+
assert.deepEqual(persisted.messages.at(-1), {
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
content: 'partial before queued redirect',
|
|
142
|
+
});
|
|
143
|
+
assert.equal(persisted.messages.some((message) => (
|
|
144
|
+
message.content === USER_INTERRUPTION_MESSAGE
|
|
145
|
+
|| message.content === TOOL_USE_INTERRUPTION_MESSAGE
|
|
146
|
+
)), false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t.test('streaming tool use: closes an unstarted call with an error result', async () => {
|
|
150
|
+
const session = createTestSession();
|
|
151
|
+
const observed = deferred();
|
|
152
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
153
|
+
opts.onToolCall?.({
|
|
154
|
+
id: 'streaming_shell',
|
|
155
|
+
name: 'shell',
|
|
156
|
+
arguments: { command: 'echo not-started' },
|
|
157
|
+
});
|
|
158
|
+
observed.resolve();
|
|
159
|
+
return waitForAbort(opts);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const asking = askSession(session.id, 'prepare a shell call', null, null, process.cwd());
|
|
163
|
+
await observed.promise;
|
|
164
|
+
abortSessionTurn(session.id, 'user-cancel');
|
|
165
|
+
await expectInterrupted(asking);
|
|
166
|
+
|
|
167
|
+
const persisted = getSession(session.id);
|
|
168
|
+
assert.equal(persisted.messages.some((message) => (
|
|
169
|
+
message.role === 'assistant'
|
|
170
|
+
&& message.toolCalls?.some((call) => call.id === 'streaming_shell')
|
|
171
|
+
)), true);
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
persisted.messages.find((message) => (
|
|
174
|
+
message.role === 'tool' && message.toolCallId === 'streaming_shell'
|
|
175
|
+
)),
|
|
176
|
+
{
|
|
177
|
+
role: 'tool',
|
|
178
|
+
content: STREAMING_INTERRUPTED_TOOL_RESULT,
|
|
179
|
+
toolCallId: 'streaming_shell',
|
|
180
|
+
toolKind: 'error',
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
assert.equal(persisted.messages.at(-1)?.content, USER_INTERRUPTION_MESSAGE);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await t.test('streamed eager tool: preserves a result completed before the provider response', async () => {
|
|
187
|
+
const session = createTestSession('readonly');
|
|
188
|
+
const eagerCompleted = deferred();
|
|
189
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
190
|
+
opts.onToolCall?.({
|
|
191
|
+
id: 'eager_read',
|
|
192
|
+
name: 'read',
|
|
193
|
+
arguments: { path: PACKAGE_PATH, offset: 0, limit: 1 },
|
|
194
|
+
});
|
|
195
|
+
return waitForAbort(opts);
|
|
196
|
+
};
|
|
197
|
+
const asking = askSession(
|
|
198
|
+
session.id,
|
|
199
|
+
'run an eager read',
|
|
200
|
+
null,
|
|
201
|
+
null,
|
|
202
|
+
process.cwd(),
|
|
203
|
+
null,
|
|
204
|
+
{
|
|
205
|
+
onToolResult: (message) => {
|
|
206
|
+
if (message?.__earlyNotify !== true) return;
|
|
207
|
+
eagerCompleted.resolve();
|
|
208
|
+
abortSessionTurn(session.id, 'user-cancel');
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
await eagerCompleted.promise;
|
|
213
|
+
await expectInterrupted(asking);
|
|
214
|
+
|
|
215
|
+
const persisted = getSession(session.id);
|
|
216
|
+
const assistant = persisted.messages.find((message) => (
|
|
217
|
+
message.role === 'assistant'
|
|
218
|
+
&& message.toolCalls?.some((call) => call.id === 'eager_read')
|
|
219
|
+
));
|
|
220
|
+
const result = persisted.messages.find((message) => (
|
|
221
|
+
message.role === 'tool' && message.toolCallId === 'eager_read'
|
|
222
|
+
));
|
|
223
|
+
assert.ok(assistant, 'streamed tool_use is retained before send() returns');
|
|
224
|
+
assert.ok(result, 'completed eager result is retained');
|
|
225
|
+
assert.notEqual(result.content, STREAMING_INTERRUPTED_TOOL_RESULT);
|
|
226
|
+
assert.equal(persisted.messages.at(-1)?.content, USER_INTERRUPTION_MESSAGE);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
let toolSession;
|
|
230
|
+
await t.test('tool execution: keeps completed results and closes unfinished calls', async () => {
|
|
231
|
+
toolSession = createTestSession();
|
|
232
|
+
provider.send = async () => ({
|
|
233
|
+
content: 'checking both slices',
|
|
234
|
+
toolCalls: [
|
|
235
|
+
{ id: 'read_one', name: 'read', arguments: { path: PACKAGE_PATH, offset: 0, limit: 1 } },
|
|
236
|
+
{ id: 'read_two', name: 'read', arguments: { path: PACKAGE_PATH, offset: 1, limit: 1 } },
|
|
237
|
+
],
|
|
238
|
+
stopReason: 'tool_use',
|
|
239
|
+
});
|
|
240
|
+
let toolResults = 0;
|
|
241
|
+
const asking = askSession(
|
|
242
|
+
toolSession.id,
|
|
243
|
+
'read two slices',
|
|
244
|
+
null,
|
|
245
|
+
null,
|
|
246
|
+
process.cwd(),
|
|
247
|
+
null,
|
|
248
|
+
{
|
|
249
|
+
onToolResult: () => {
|
|
250
|
+
toolResults += 1;
|
|
251
|
+
if (toolResults === 1) abortSessionTurn(toolSession.id, 'user-cancel');
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
await expectInterrupted(asking);
|
|
256
|
+
|
|
257
|
+
const persisted = getSession(toolSession.id);
|
|
258
|
+
const results = persisted.messages.filter((message) => message.role === 'tool');
|
|
259
|
+
assert.equal(results.length, 2);
|
|
260
|
+
assert.equal(results[0].toolCallId, 'read_one');
|
|
261
|
+
assert.notEqual(results[0].content, TOOL_USE_REJECT_RESULT);
|
|
262
|
+
assert.deepEqual(
|
|
263
|
+
{ id: results[1].toolCallId, content: results[1].content, kind: results[1].toolKind },
|
|
264
|
+
{ id: 'read_two', content: TOOL_USE_REJECT_RESULT, kind: 'error' },
|
|
265
|
+
);
|
|
266
|
+
assert.equal(persisted.messages.at(-1)?.content, TOOL_USE_INTERRUPTION_MESSAGE);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await t.test('recovery: the next model request receives the closed tool trajectory', async () => {
|
|
270
|
+
let capturedMessages = null;
|
|
271
|
+
provider.send = async (messages, _model, _tools, opts) => {
|
|
272
|
+
capturedMessages = JSON.parse(JSON.stringify(messages));
|
|
273
|
+
opts.onTextDelta?.('recovered');
|
|
274
|
+
return { content: 'recovered', stopReason: 'STOP' };
|
|
275
|
+
};
|
|
276
|
+
const result = await askSession(toolSession.id, 'continue after interrupt', null, null, process.cwd());
|
|
277
|
+
assert.equal(result.content, 'recovered');
|
|
278
|
+
assert.equal(capturedMessages.some((message) => (
|
|
279
|
+
message.role === 'tool'
|
|
280
|
+
&& message.toolCallId === 'read_two'
|
|
281
|
+
&& message.content === TOOL_USE_REJECT_RESULT
|
|
282
|
+
)), true);
|
|
283
|
+
assert.equal(capturedMessages.some((message) => message.content === TOOL_USE_INTERRUPTION_MESSAGE), true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
let chainedSession;
|
|
287
|
+
await t.test('later iteration: preserves completed tool history, steering, and the current partial', async () => {
|
|
288
|
+
chainedSession = createTestSession();
|
|
289
|
+
let sendCount = 0;
|
|
290
|
+
const secondStream = deferred();
|
|
291
|
+
provider.send = async (_messages, _model, _tools, opts) => {
|
|
292
|
+
sendCount += 1;
|
|
293
|
+
if (sendCount === 1) {
|
|
294
|
+
opts.onTextDelta?.('first iteration preamble');
|
|
295
|
+
return {
|
|
296
|
+
content: 'first iteration preamble',
|
|
297
|
+
toolCalls: [
|
|
298
|
+
{ id: 'chain_read', name: 'read', arguments: { path: PACKAGE_PATH, offset: 0, limit: 1 } },
|
|
299
|
+
],
|
|
300
|
+
stopReason: 'tool_use',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
opts.onTextDelta?.('later partial');
|
|
304
|
+
secondStream.resolve();
|
|
305
|
+
return waitForAbort(opts);
|
|
306
|
+
};
|
|
307
|
+
let steeringDrained = false;
|
|
308
|
+
const asking = askSession(
|
|
309
|
+
chainedSession.id,
|
|
310
|
+
'run a chained turn',
|
|
311
|
+
null,
|
|
312
|
+
null,
|
|
313
|
+
process.cwd(),
|
|
314
|
+
null,
|
|
315
|
+
{
|
|
316
|
+
onTextDelta: () => {},
|
|
317
|
+
drainSteering: () => {
|
|
318
|
+
if (steeringDrained) return [];
|
|
319
|
+
steeringDrained = true;
|
|
320
|
+
return [{ content: 'queued redirect', text: 'queued redirect', count: 1 }];
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
await secondStream.promise;
|
|
325
|
+
abortSessionTurn(chainedSession.id, 'user-cancel');
|
|
326
|
+
await expectInterrupted(asking);
|
|
327
|
+
|
|
328
|
+
const persisted = getSession(chainedSession.id);
|
|
329
|
+
assert.equal(persisted.messages.some((message) => (
|
|
330
|
+
message.role === 'tool' && message.toolCallId === 'chain_read'
|
|
331
|
+
)), true);
|
|
332
|
+
assert.equal(persisted.messages.some((message) => (
|
|
333
|
+
message.role === 'user'
|
|
334
|
+
&& message.content === 'queued redirect'
|
|
335
|
+
&& message.meta?.source === 'steering'
|
|
336
|
+
)), true);
|
|
337
|
+
assert.equal(persisted.messages.some((message) => (
|
|
338
|
+
message.role === 'assistant'
|
|
339
|
+
&& message.content === 'later partial'
|
|
340
|
+
)), true);
|
|
341
|
+
assert.equal(persisted.messages.at(-1)?.content, USER_INTERRUPTION_MESSAGE);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await t.test('recovery and disk: partial/steering history survives the next send and storage flush', async () => {
|
|
345
|
+
let capturedMessages = null;
|
|
346
|
+
provider.send = async (messages) => {
|
|
347
|
+
capturedMessages = JSON.parse(JSON.stringify(messages));
|
|
348
|
+
return { content: 'chain recovered', stopReason: 'STOP' };
|
|
349
|
+
};
|
|
350
|
+
await askSession(chainedSession.id, 'finish the chain', null, null, process.cwd());
|
|
351
|
+
assert.equal(capturedMessages.some((message) => (
|
|
352
|
+
message.role === 'assistant'
|
|
353
|
+
&& message.content === 'later partial'
|
|
354
|
+
)), true);
|
|
355
|
+
assert.equal(capturedMessages.some((message) => (
|
|
356
|
+
message.role === 'user' && message.content === 'queued redirect'
|
|
357
|
+
)), true);
|
|
358
|
+
|
|
359
|
+
const latestSession = getSession(chainedSession.id);
|
|
360
|
+
await saveSessionAsync(latestSession, { expectedGeneration: latestSession.generation });
|
|
361
|
+
const diskSession = JSON.parse(readFileSync(
|
|
362
|
+
join(DATA_DIR, 'sessions', `${chainedSession.id}.json`),
|
|
363
|
+
'utf8',
|
|
364
|
+
));
|
|
365
|
+
assert.equal(diskSession.messages.some((message) => (
|
|
366
|
+
message.role === 'assistant' && message.content === 'later partial'
|
|
367
|
+
)), true);
|
|
368
|
+
assert.equal(diskSession.messages.some((message) => message.content === USER_INTERRUPTION_MESSAGE), true);
|
|
369
|
+
assert.equal(Object.hasOwn(diskSession, 'liveTurnMessages'), false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createLifecycleApi } from '../src/session-runtime/lifecycle-api.mjs';
|
|
5
|
+
import {
|
|
6
|
+
_clearWebSocketPoolForTest,
|
|
7
|
+
_seedWebSocketEntryForTest,
|
|
8
|
+
closeOpenaiWsPoolForSession,
|
|
9
|
+
} from '../src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs';
|
|
10
|
+
|
|
11
|
+
function socket() {
|
|
12
|
+
return {
|
|
13
|
+
closed: [],
|
|
14
|
+
close(_code, reason) {
|
|
15
|
+
this.closed.push(reason);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function lifecycleFor(session) {
|
|
21
|
+
let current = session;
|
|
22
|
+
return createLifecycleApi({
|
|
23
|
+
getSession: () => current,
|
|
24
|
+
setSession: (value) => { current = value; },
|
|
25
|
+
getRoute: () => ({}),
|
|
26
|
+
setRoute: () => {},
|
|
27
|
+
getConfig: () => ({}),
|
|
28
|
+
getMode: () => 'full',
|
|
29
|
+
getCurrentCwd: () => '/test',
|
|
30
|
+
setCloseRequested: () => {},
|
|
31
|
+
getMemoryModPromise: () => null,
|
|
32
|
+
setMemoryModPromise: () => {},
|
|
33
|
+
setSessionNeedsCwdRefresh: () => {},
|
|
34
|
+
hooks: { dispatch: () => {}, flushRules: () => {} },
|
|
35
|
+
hookCommonPayload: (payload) => payload,
|
|
36
|
+
mgr: {
|
|
37
|
+
closeSession: (id, reason) => {
|
|
38
|
+
closeOpenaiWsPoolForSession(id, `session-close:${reason}`);
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
statusRoutes: { clearGatewaySessionRoute: () => {} },
|
|
43
|
+
channels: { stop: () => null },
|
|
44
|
+
agentTool: { closeAll: () => {} },
|
|
45
|
+
mcpClient: { disconnectAll: () => null },
|
|
46
|
+
warmupTimers: {},
|
|
47
|
+
prewarmTimers: {},
|
|
48
|
+
flushConfigSave: () => {},
|
|
49
|
+
flushBackendSave: () => {},
|
|
50
|
+
flushOutputStyleSave: () => {},
|
|
51
|
+
withTeardownDeadline: (promise) => promise,
|
|
52
|
+
closePatchRuntimeIfLoaded: () => null,
|
|
53
|
+
invalidateContextStatusCache: () => {},
|
|
54
|
+
invalidatePreSessionToolSurface: () => {},
|
|
55
|
+
notificationListeners: { clear: () => {} },
|
|
56
|
+
remoteStateListeners: { clear: () => {} },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('lifecycle drains the OpenAI WS pool only for process exit', async () => {
|
|
61
|
+
_clearWebSocketPoolForTest();
|
|
62
|
+
globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
|
|
63
|
+
const replacementSocket = socket();
|
|
64
|
+
const retainedSocket = socket();
|
|
65
|
+
_seedWebSocketEntryForTest({ poolKey: 'replacement', auth: {}, cacheKey: '', entry: { socket: replacementSocket } });
|
|
66
|
+
_seedWebSocketEntryForTest({ poolKey: 'retained', auth: {}, cacheKey: '', entry: { socket: retainedSocket } });
|
|
67
|
+
|
|
68
|
+
await lifecycleFor({ id: 'replacement', messages: [], liveTurnMessages: [] }).close('engine-replace');
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(replacementSocket.closed, ['session-close:engine-replace']);
|
|
71
|
+
assert.deepEqual(retainedSocket.closed, []);
|
|
72
|
+
|
|
73
|
+
await lifecycleFor({ id: 'exit-session', messages: [], liveTurnMessages: [] }).close('cli-exit');
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(retainedSocket.closed, ['cli-exit']);
|
|
76
|
+
});
|
|
@@ -37,6 +37,7 @@ async function run(provider, messages = [{ role: 'user', content: 'answer fully'
|
|
|
37
37
|
return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
|
|
38
38
|
onTextDelta: options.onTextDelta,
|
|
39
39
|
session: options.session,
|
|
40
|
+
providerState: options.providerState,
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -117,6 +118,45 @@ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery
|
|
|
117
118
|
assert.equal(provider.sent[1][1].content, 'gemini ');
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
test('Gemini MAX_TOKENS continuation preserves provider-scoped replay metadata', async () => {
|
|
122
|
+
const providerMetadata = {
|
|
123
|
+
gemini: { thoughtParts: [{ text: 'signed thought', thoughtSignature: 'sig-gemini' }] },
|
|
124
|
+
};
|
|
125
|
+
const incomplete = Object.assign(new Error('MAX_TOKENS'), {
|
|
126
|
+
code: 'PROVIDER_INCOMPLETE',
|
|
127
|
+
providerIncomplete: true,
|
|
128
|
+
finishReason: 'MAX_TOKENS',
|
|
129
|
+
partialContent: 'first ',
|
|
130
|
+
providerMetadata,
|
|
131
|
+
});
|
|
132
|
+
const provider = queuedProvider([
|
|
133
|
+
incomplete,
|
|
134
|
+
{ content: 'second', stopReason: 'end_turn' },
|
|
135
|
+
]);
|
|
136
|
+
await run(provider);
|
|
137
|
+
assert.deepEqual(provider.sent[1][1].providerMetadata, providerMetadata);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('Gemini partial-final stall commits signed provider metadata with partial text', async () => {
|
|
141
|
+
const providerMetadata = {
|
|
142
|
+
gemini: {
|
|
143
|
+
thoughtParts: [{ text: 'private', thoughtSignature: 'sig-private' }],
|
|
144
|
+
textParts: [{ text: 'partial answer', thoughtSignature: 'sig-text' }],
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const stalled = Object.assign(new Error('stalled'), {
|
|
148
|
+
streamStalled: true,
|
|
149
|
+
pendingToolUse: false,
|
|
150
|
+
emittedToolCall: false,
|
|
151
|
+
partialContent: 'partial answer',
|
|
152
|
+
providerMetadata,
|
|
153
|
+
});
|
|
154
|
+
const messages = [{ role: 'user', content: 'answer' }];
|
|
155
|
+
const result = await run(queuedProvider([stalled]), messages);
|
|
156
|
+
assert.equal(result.content, 'partial answer');
|
|
157
|
+
assert.deepEqual(result.providerMetadata, providerMetadata);
|
|
158
|
+
});
|
|
159
|
+
|
|
120
160
|
test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not recover', async () => {
|
|
121
161
|
for (const stopReason of ['pause_turn', 'OTHER']) {
|
|
122
162
|
const provider = queuedProvider([{ content: `partial-${stopReason}`, stopReason }]);
|
|
@@ -245,6 +285,21 @@ test('clean end_turn is unchanged and does not enter recovery', async () => {
|
|
|
245
285
|
assert.deepEqual(messages, [{ role: 'user', content: 'answer fully' }]);
|
|
246
286
|
});
|
|
247
287
|
|
|
288
|
+
test('providerState distinguishes no update from an explicit clear for other providers', async () => {
|
|
289
|
+
const initial = { otherProvider: { continuation: 'keep' } };
|
|
290
|
+
const noUpdate = await run(queuedProvider([
|
|
291
|
+
{ content: 'done', stopReason: 'end_turn' },
|
|
292
|
+
]), undefined, { providerState: initial });
|
|
293
|
+
assert.deepEqual(noUpdate.providerState, initial);
|
|
294
|
+
assert.equal(noUpdate.providerStateUpdated, false);
|
|
295
|
+
|
|
296
|
+
const cleared = await run(queuedProvider([
|
|
297
|
+
{ content: 'done', stopReason: 'end_turn', providerState: null },
|
|
298
|
+
]), undefined, { providerState: initial });
|
|
299
|
+
assert.equal(cleared.providerState, null);
|
|
300
|
+
assert.equal(cleared.providerStateUpdated, true);
|
|
301
|
+
});
|
|
302
|
+
|
|
248
303
|
test('tool-call turns keep the normal tool execution path', async () => {
|
|
249
304
|
const provider = queuedProvider([
|
|
250
305
|
{
|