mixdog 0.9.51 → 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.
Files changed (110) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -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
  {
@@ -11,13 +11,14 @@ import test from 'node:test';
11
11
  import assert from 'node:assert/strict';
12
12
  import {
13
13
  applyDeferredToolSurface,
14
+ rebuildDeferredToolSurfaceForProvider,
14
15
  refreshInitialDeferredMcpSurface,
15
16
  reconcileDeferredMcpToolCatalog,
16
17
  } from '../src/session-runtime/tool-catalog.mjs';
17
18
 
18
- function baseSession() {
19
+ function baseSession(provider = 'anthropic-oauth') {
19
20
  return {
20
- provider: 'legacy',
21
+ provider,
21
22
  tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
22
23
  messages: [{ role: 'system', content: 'BASE PROMPT' }],
23
24
  };
@@ -26,9 +27,9 @@ function baseSession() {
26
27
  // A freshly-created session: the create-time surface is baked WITHOUT any MCP
27
28
  // (server still mid-handshake at create). `shell` is a deferred (non-active)
28
29
  // standalone tool so BP1 carries a manifest block even before MCP arrives.
29
- function createdSession() {
30
- const session = baseSession();
31
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
30
+ function createdSession(provider = 'anthropic-oauth') {
31
+ const session = baseSession(provider);
32
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: session.provider });
32
33
  return session;
33
34
  }
34
35
 
@@ -93,7 +94,7 @@ test('recreated session (MCP already connected at create) seeds its manifest, no
93
94
  // surface at create time, so a cwd-change recreate seeds its BP1 directly and
94
95
  // never needs the first-turn refresh.
95
96
  const session = baseSession();
96
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
97
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: session.provider });
97
98
  assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
98
99
  assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
99
100
 
@@ -128,7 +129,7 @@ test('a second newly-connected MCP tool re-renders BP1 in place (both listed, st
128
129
  test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
129
130
  // A prior run baked BP1 with the MCP tool; resume reloads that transcript.
130
131
  const session = baseSession();
131
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
132
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: session.provider });
132
133
  const before = systemContent(session);
133
134
  const announcedBefore = [...session.deferredAnnouncedTools];
134
135
  // Resume path never sets the per-session fresh flag.
@@ -139,11 +140,86 @@ test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its
139
140
  assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
140
141
  });
141
142
 
142
- test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
143
- const session = createdSession();
143
+ test('fresh-session refresh state is isolated, one-shot, and survives provider switches without leaking', () => {
144
+ const refreshedSession = createdSession();
145
+ const noOpSession = createdSession();
146
+ refreshedSession.deferredInitialRefreshPending = true;
147
+ noOpSession.deferredInitialRefreshPending = true;
148
+
149
+ assert.equal(firstTurnGate(refreshedSession, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
150
+ assert.equal(refreshedSession.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
151
+ assert.equal(noOpSession.deferredInitialRefreshPending, true, 'consuming one session does not consume another');
152
+ assert.ok(refreshedSession.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
153
+ assert.ok(!noOpSession.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'announced state does not leak between sessions');
154
+ assert.equal(firstTurnGate(refreshedSession, [mcpTool2]), 'late', 'second turn no longer refreshes');
155
+
156
+ rebuildDeferredToolSurfaceForProvider(noOpSession, 'xai');
157
+ noOpSession.provider = 'xai';
158
+ const beforeNoOp = systemContent(noOpSession);
159
+ assert.equal(beforeNoOp.includes('<available-deferred-tools>'), false, 'canonical switch removes native manifest state');
160
+ assert.equal(firstTurnGate(noOpSession, []), 'refresh-noop', 'canonical provider consumes an empty one-shot without a manifest refresh');
161
+ assert.equal(noOpSession.deferredInitialRefreshPending, false, 'no-op also consumes the fresh flag');
162
+ assert.equal(systemContent(noOpSession), beforeNoOp, 'canonical no-op leaves the initial manifest byte-identical');
163
+
164
+ rebuildDeferredToolSurfaceForProvider(noOpSession, 'anthropic-oauth');
165
+ noOpSession.provider = 'anthropic-oauth';
166
+ assert.equal(firstTurnGate(noOpSession, [mcpTool2]), 'late', 'switching back cannot resurrect first-turn refresh state');
167
+ assert.ok(!noOpSession.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'post-switch tool remains eligible for the late path');
168
+ });
169
+
170
+ test('legacy canonical snapshot gains a grace-connected MCP tool for the first turn and session lifetime', () => {
171
+ const session = createdSession('legacy');
144
172
  session.deferredInitialRefreshPending = true;
145
- assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
146
- assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
147
- assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
148
- assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
173
+ const before = systemContent(session);
174
+
175
+ assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'grace fold updates the canonical snapshot');
176
+ assert.ok(session.tools.some((tool) => tool.name === mcpTool.name), 'tool is provider-visible on the first turn');
177
+ assert.ok(session.deferredCallableTools.includes(mcpTool.name), 'tool is callable for the session');
178
+ assert.ok(session.deferredToolCatalog.some((tool) => tool.name === mcpTool.name), 'tool persists in the session catalog');
179
+ assert.equal(systemContent(session), before, 'canonical refresh does not introduce a native manifest');
180
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false);
181
+
182
+ const toolsOnce = JSON.stringify(session.tools);
183
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'repeated fold is a no-op');
184
+ assert.equal(JSON.stringify(session.tools), toolsOnce, 'repeated fold leaves the fixed surface byte-identical');
185
+ assert.equal(firstTurnGate(session, [mcpTool]), 'late', 'one-shot gate remains consumed');
186
+ });
187
+
188
+ test('legacy to Anthropic and back preserves grace MCP availability without manifest leakage or duplication', () => {
189
+ const session = createdSession('legacy');
190
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true);
191
+
192
+ rebuildDeferredToolSurfaceForProvider(session, 'anthropic-oauth');
193
+ session.provider = 'anthropic-oauth';
194
+ assert.ok(session.deferredToolCatalog.some((tool) => tool.name === mcpTool.name), 'native switch retains the MCP catalog entry');
195
+ assert.ok(systemContent(session).includes(mcpTool.name), 'native switch advertises the retained deferred tool');
196
+ assert.equal((systemContent(session).match(/<available-deferred-tools>/g) || []).length, 1, 'native switch creates one manifest');
197
+
198
+ rebuildDeferredToolSurfaceForProvider(session, 'legacy');
199
+ session.provider = 'legacy';
200
+ assert.ok(session.tools.some((tool) => tool.name === mcpTool.name), 'canonical switch restores the MCP tool to the active surface');
201
+ assert.ok(session.deferredCallableTools.includes(mcpTool.name), 'canonical switch keeps the MCP tool callable');
202
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false, 'native manifest does not leak back to canonical');
203
+ assert.deepEqual(session.deferredAnnouncedTools, [], 'native announcement state does not leak back to canonical');
204
+
205
+ rebuildDeferredToolSurfaceForProvider(session, 'anthropic-oauth');
206
+ session.provider = 'anthropic-oauth';
207
+ assert.equal((systemContent(session).match(/<available-deferred-tools>/g) || []).length, 1, 'repeat switch still creates only one manifest');
208
+ });
209
+
210
+ test('canonical MCP arriving after the first-turn grace keeps the fixed snapshot and emits no late reminder', () => {
211
+ const session = createdSession('legacy');
212
+ session.deferredInitialRefreshPending = true;
213
+ assert.equal(firstTurnGate(session, []), 'refresh-noop');
214
+ const before = JSON.stringify(session.tools);
215
+ let enqueued = null;
216
+
217
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool2], {
218
+ enqueue: (text) => { enqueued = text; return true; },
219
+ });
220
+ assert.equal(result, null, 'canonical reconciliation keeps the create/grace snapshot fixed');
221
+ assert.equal(JSON.stringify(session.tools), before, 'post-grace MCP does not churn the canonical surface');
222
+ assert.equal(session.deferredToolCatalog.some((tool) => tool.name === mcpTool2.name), false);
223
+ assert.equal(enqueued, null, 'canonical provider emits no native late reminder');
224
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false);
149
225
  });