mixdog 0.9.53 → 0.9.55
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 +2 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.55",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"bench:corpus": "node scripts/routing-corpus.mjs",
|
|
86
86
|
"bench:run": "node scripts/bench-run.mjs",
|
|
87
87
|
"bench:recall": "node scripts/recall-bench.mjs",
|
|
88
|
+
"bench:tui-load": "node scripts/tui-runtime-load-bench.mjs",
|
|
88
89
|
"audit:models": "node scripts/model-catalog-audit.mjs",
|
|
89
90
|
"patch:replay": "node scripts/patch-replay.mjs",
|
|
90
91
|
"build:tui": "node scripts/build-tui.mjs",
|
|
@@ -27,6 +27,11 @@ import { parseSSEStream } from '../src/runtime/agent/orchestrator/providers/anth
|
|
|
27
27
|
import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
|
|
28
28
|
import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
|
|
29
29
|
import { shouldFallbackTransport } from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
|
|
30
|
+
import {
|
|
31
|
+
PROVIDER_MAX_BEFORE_WARN_MS,
|
|
32
|
+
PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
33
|
+
PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
34
|
+
} from '../src/runtime/agent/orchestrator/stall-policy.mjs';
|
|
30
35
|
import {
|
|
31
36
|
ProviderAdmissionScheduler,
|
|
32
37
|
wrapProviderAdmission,
|
|
@@ -39,6 +44,12 @@ const policy = {
|
|
|
39
44
|
toolRunningMs: 600_000,
|
|
40
45
|
};
|
|
41
46
|
|
|
47
|
+
test('OpenAI HTTP and WS semantic idle defaults share the pre-watchdog ceiling', () => {
|
|
48
|
+
assert.equal(PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS, PROVIDER_MAX_BEFORE_WARN_MS);
|
|
49
|
+
assert.equal(PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS, PROVIDER_MAX_BEFORE_WARN_MS);
|
|
50
|
+
assert.ok(PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS < 300_000);
|
|
51
|
+
});
|
|
52
|
+
|
|
42
53
|
function seededSnapshot(id, elapsedMs) {
|
|
43
54
|
markSessionAskStart(id);
|
|
44
55
|
const entry = _getRuntimeEntry(id);
|
|
@@ -436,6 +447,63 @@ function openAiHttpResponse(events) {
|
|
|
436
447
|
};
|
|
437
448
|
}
|
|
438
449
|
|
|
450
|
+
test('OAuth HTTP retries refresh requesting-stage liveness for every attempt', async (t) => {
|
|
451
|
+
const id = `liveness-oauth-http-retries-${Date.now()}`;
|
|
452
|
+
t.after(() => _clearSessionRuntime(id));
|
|
453
|
+
markSessionAskStart(id);
|
|
454
|
+
const entry = _getRuntimeEntry(id);
|
|
455
|
+
const stages = [];
|
|
456
|
+
const scheduler = new ProviderAdmissionScheduler({ concurrency: 1 });
|
|
457
|
+
let fetchCalls = 0;
|
|
458
|
+
|
|
459
|
+
const provider = wrapProviderAdmission({
|
|
460
|
+
async send(_messages, _model, _tools, opts) {
|
|
461
|
+
return sendViaHttpSse({
|
|
462
|
+
auth: { type: 'openai-direct', apiKey: 'test' },
|
|
463
|
+
body: {},
|
|
464
|
+
opts: {},
|
|
465
|
+
useModel: 'gpt',
|
|
466
|
+
onStageChange: opts?.onStageChange,
|
|
467
|
+
fetchFn: async () => {
|
|
468
|
+
fetchCalls += 1;
|
|
469
|
+
if (fetchCalls <= 4) throw new Error('transient request failure');
|
|
470
|
+
return openAiHttpResponse([
|
|
471
|
+
{ type: 'response.completed', response: { id: 'r1', model: 'gpt', status: 'completed', output: [] } },
|
|
472
|
+
]);
|
|
473
|
+
},
|
|
474
|
+
_sleepFn: async () => {},
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
}, 'liveness-oauth-http-retries', scheduler);
|
|
478
|
+
await provider.send([], 'gpt', [], {
|
|
479
|
+
onStageChange: (stage, detail) => {
|
|
480
|
+
// Set a known stale value before the real ask-session liveness
|
|
481
|
+
// update. The post-send assertion therefore fails if this callback
|
|
482
|
+
// no longer refreshes lastProgressAt.
|
|
483
|
+
entry.lastProgressAt = 1;
|
|
484
|
+
updateSessionStage(id, stage);
|
|
485
|
+
stages.push({ stage, detail, lastProgressAt: entry.lastProgressAt });
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
assert.equal(fetchCalls, 5);
|
|
490
|
+
// Admission emits the first requesting signal, followed by one from each
|
|
491
|
+
// HTTP attempt. Keep the total explicit so stage-only consumers cannot
|
|
492
|
+
// silently dedupe the retry heartbeats.
|
|
493
|
+
const requestingStages = stages.filter(({ stage }) => stage === 'requesting');
|
|
494
|
+
assert.equal(stages.length, 7);
|
|
495
|
+
assert.deepEqual(requestingStages.map(({ stage }) => stage), Array(6).fill('requesting'));
|
|
496
|
+
assert.ok(requestingStages.every(({ lastProgressAt }) => lastProgressAt > 1));
|
|
497
|
+
assert.equal(requestingStages[0].detail, undefined);
|
|
498
|
+
assert.deepEqual(requestingStages.slice(1).map(({ detail }) => detail), [
|
|
499
|
+
{ attempt: 1, maxAttempts: 5, retry: false },
|
|
500
|
+
{ attempt: 2, maxAttempts: 5, retry: true },
|
|
501
|
+
{ attempt: 3, maxAttempts: 5, retry: true },
|
|
502
|
+
{ attempt: 4, maxAttempts: 5, retry: true },
|
|
503
|
+
{ attempt: 5, maxAttempts: 5, retry: true },
|
|
504
|
+
]);
|
|
505
|
+
});
|
|
506
|
+
|
|
439
507
|
function wedgedOpenAiHttpResponse() {
|
|
440
508
|
return {
|
|
441
509
|
ok: true,
|
|
@@ -16,6 +16,26 @@ import {
|
|
|
16
16
|
import { _midstreamSleepWithAbort, parseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-sse.mjs';
|
|
17
17
|
import { AnthropicProvider } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
|
|
18
18
|
import { AnthropicOAuthProvider } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
|
|
19
|
+
import {
|
|
20
|
+
ANTHROPIC_CACHE_TTL_STABLE,
|
|
21
|
+
ANTHROPIC_CACHE_TTL_VOLATILE,
|
|
22
|
+
resolveAnthropicCacheTtls,
|
|
23
|
+
} from '../src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs';
|
|
24
|
+
|
|
25
|
+
test('Anthropic cache defaults keep system/tier3 stable and messages volatile', () => {
|
|
26
|
+
assert.deepEqual(resolveAnthropicCacheTtls(), {
|
|
27
|
+
tools: null,
|
|
28
|
+
system: ANTHROPIC_CACHE_TTL_STABLE,
|
|
29
|
+
tier3: ANTHROPIC_CACHE_TTL_STABLE,
|
|
30
|
+
messages: ANTHROPIC_CACHE_TTL_VOLATILE,
|
|
31
|
+
});
|
|
32
|
+
const descending = resolveAnthropicCacheTtls({
|
|
33
|
+
cacheStrategy: { system: '5m', tier3: '1h', messages: '1h' },
|
|
34
|
+
});
|
|
35
|
+
assert.equal(descending.system, ANTHROPIC_CACHE_TTL_VOLATILE);
|
|
36
|
+
assert.equal(descending.tier3, ANTHROPIC_CACHE_TTL_VOLATILE);
|
|
37
|
+
assert.equal(descending.messages, ANTHROPIC_CACHE_TTL_VOLATILE);
|
|
38
|
+
});
|
|
19
39
|
|
|
20
40
|
test('Anthropic transport classifier covers every 5xx, 529, and nested connection errno', () => {
|
|
21
41
|
for (const status of [500, 501, 505, 529, 599]) {
|
|
@@ -110,6 +130,33 @@ test('permanent quota 429 is not retried by the generic rate-limit path', async
|
|
|
110
130
|
assert.equal(classifyError(quota), 'permanent');
|
|
111
131
|
});
|
|
112
132
|
|
|
133
|
+
test('OAuth subscription 429 fails immediately without honoring Retry-After', async () => {
|
|
134
|
+
const oauth = Object.create(AnthropicOAuthProvider.prototype);
|
|
135
|
+
oauth.credentials = { accessToken: 'fixture', expiresAt: Date.now() + 60_000 };
|
|
136
|
+
oauth.config = {};
|
|
137
|
+
oauth.fastModeBetaHeaderLatched = false;
|
|
138
|
+
oauth.ensureAuth = async () => oauth.credentials;
|
|
139
|
+
oauth.scrubTokens = (text) => text;
|
|
140
|
+
let attempts = 0;
|
|
141
|
+
const limited = {
|
|
142
|
+
status: 429,
|
|
143
|
+
ok: false,
|
|
144
|
+
headers: new Map([['retry-after', '14400']]),
|
|
145
|
+
async text() { return 'subscription rate limit'; },
|
|
146
|
+
};
|
|
147
|
+
await assert.rejects(oauth.send([], 'claude-sonnet-4-6', [], {
|
|
148
|
+
_doRequestFn: async () => {
|
|
149
|
+
attempts += 1;
|
|
150
|
+
return {
|
|
151
|
+
response: limited,
|
|
152
|
+
controller: { abort() {} },
|
|
153
|
+
cancelHandler: null,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
}), (err) => err?.status === 429 && err?.retryAfterMs === 14_400_000);
|
|
157
|
+
assert.equal(attempts, 1);
|
|
158
|
+
});
|
|
159
|
+
|
|
113
160
|
test('structured and nested permanent quota codes are terminal; rate-limit codes retry', async () => {
|
|
114
161
|
const terminalCases = [
|
|
115
162
|
Object.assign(new Error('generic 429'), { status: 429, code: 'insufficient_quota' }),
|
|
@@ -220,6 +267,191 @@ function successfulAnthropicResponse(content = 'fallback-ok') {
|
|
|
220
267
|
};
|
|
221
268
|
}
|
|
222
269
|
|
|
270
|
+
function nonStreamingAnthropicMessage(content = 'replacement') {
|
|
271
|
+
return {
|
|
272
|
+
id: 'msg_fixture',
|
|
273
|
+
model: 'claude-sonnet-4-6',
|
|
274
|
+
content: [{ type: 'text', text: content }],
|
|
275
|
+
stop_reason: 'end_turn',
|
|
276
|
+
usage: { input_tokens: 2, output_tokens: 1 },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
test('session plumbing fail-closes stream replacement and preserves unique output', { concurrency: false }, async (t) => {
|
|
281
|
+
const priorRetries = process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
282
|
+
process.env.CLAUDE_CODE_MAX_RETRIES = '0';
|
|
283
|
+
try {
|
|
284
|
+
process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
|
|
285
|
+
const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
|
|
286
|
+
await initProviders({ anthropic: { enabled: true, apiKey: 'fixture-key' } });
|
|
287
|
+
const provider = getProvider('anthropic');
|
|
288
|
+
const {
|
|
289
|
+
abortSessionTurn,
|
|
290
|
+
askSession,
|
|
291
|
+
createSession,
|
|
292
|
+
getSession,
|
|
293
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
|
|
294
|
+
|
|
295
|
+
const makeDroppedResponse = () => {
|
|
296
|
+
const partial = new TextEncoder().encode([
|
|
297
|
+
{ type: 'message_start', message: { model: 'claude-sonnet-4-6', usage: { input_tokens: 1 } } },
|
|
298
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text' } },
|
|
299
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'partial' } },
|
|
300
|
+
].map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''));
|
|
301
|
+
let reads = 0;
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
status: 200,
|
|
305
|
+
headers: new Map(),
|
|
306
|
+
body: { getReader: () => ({
|
|
307
|
+
async read() {
|
|
308
|
+
if (reads++ === 0) return { done: false, value: partial };
|
|
309
|
+
throw Object.assign(new Error('body stream terminated'), { code: 'ECONNRESET' });
|
|
310
|
+
},
|
|
311
|
+
async cancel() {},
|
|
312
|
+
releaseLock() {},
|
|
313
|
+
}) },
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
const createTestSession = () => createSession({
|
|
317
|
+
provider: 'anthropic',
|
|
318
|
+
model: 'claude-sonnet-4-6',
|
|
319
|
+
tools: [],
|
|
320
|
+
cwd: process.cwd(),
|
|
321
|
+
skipAgentRules: true,
|
|
322
|
+
skipSkills: true,
|
|
323
|
+
compaction: { auto: false },
|
|
324
|
+
});
|
|
325
|
+
const partialsInHistory = (session) => session.messages.filter((message) => (
|
|
326
|
+
message.role === 'assistant' && message.content === 'partial'
|
|
327
|
+
)).length;
|
|
328
|
+
const runCase = async ({ reset, restartFails = false, cancelRestart = false }) => {
|
|
329
|
+
const session = createTestSession();
|
|
330
|
+
let calls = 0;
|
|
331
|
+
let visible = '';
|
|
332
|
+
let markRestartEntered;
|
|
333
|
+
const restartEntered = new Promise((resolve) => { markRestartEntered = resolve; });
|
|
334
|
+
provider.client = { messages: { create(params, requestOptions = {}) {
|
|
335
|
+
calls += 1;
|
|
336
|
+
if (params.stream === false) {
|
|
337
|
+
if (restartFails) throw new Error('non-streaming restart failed');
|
|
338
|
+
if (cancelRestart) {
|
|
339
|
+
markRestartEntered();
|
|
340
|
+
return new Promise((_resolve, reject) => {
|
|
341
|
+
const signal = requestOptions.signal;
|
|
342
|
+
const rejectFromAbort = () => reject(
|
|
343
|
+
signal?.reason instanceof Error
|
|
344
|
+
? signal.reason
|
|
345
|
+
: new Error('non-streaming restart canceled'),
|
|
346
|
+
);
|
|
347
|
+
if (signal?.aborted) rejectFromAbort();
|
|
348
|
+
else signal?.addEventListener('abort', rejectFromAbort, { once: true });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return Promise.resolve(nonStreamingAnthropicMessage('replacement'));
|
|
352
|
+
}
|
|
353
|
+
return { asResponse: async () => makeDroppedResponse() };
|
|
354
|
+
} } };
|
|
355
|
+
const options = {
|
|
356
|
+
onTextDelta: (chunk) => { visible += chunk; },
|
|
357
|
+
...(reset ? {
|
|
358
|
+
onTextReset: async ({ chars }) => reset({
|
|
359
|
+
chars,
|
|
360
|
+
getVisible: () => visible,
|
|
361
|
+
setVisible: (value) => { visible = value; },
|
|
362
|
+
}),
|
|
363
|
+
} : {}),
|
|
364
|
+
};
|
|
365
|
+
const asking = askSession(
|
|
366
|
+
session.id,
|
|
367
|
+
'fixture prompt',
|
|
368
|
+
null,
|
|
369
|
+
null,
|
|
370
|
+
process.cwd(),
|
|
371
|
+
null,
|
|
372
|
+
options,
|
|
373
|
+
);
|
|
374
|
+
if (cancelRestart) {
|
|
375
|
+
await restartEntered;
|
|
376
|
+
assert.equal(abortSessionTurn(session.id, 'user-cancel'), true);
|
|
377
|
+
}
|
|
378
|
+
const outcome = await asking.then(
|
|
379
|
+
(result) => ({ result }),
|
|
380
|
+
(error) => ({ error }),
|
|
381
|
+
);
|
|
382
|
+
return {
|
|
383
|
+
...outcome,
|
|
384
|
+
calls,
|
|
385
|
+
visible,
|
|
386
|
+
session: getSession(session.id),
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
for (const [name, reset] of [
|
|
391
|
+
['absent acknowledgment', null],
|
|
392
|
+
['false acknowledgment', async () => false],
|
|
393
|
+
['throwing acknowledgment', async () => { throw new Error('reset failed'); }],
|
|
394
|
+
]) {
|
|
395
|
+
await t.test(name, async () => {
|
|
396
|
+
const outcome = await runCase({ reset });
|
|
397
|
+
assert.match(outcome.error?.message || '', /body stream terminated/);
|
|
398
|
+
assert.equal(outcome.calls, 1, 'must not start non-streaming request');
|
|
399
|
+
assert.equal(outcome.visible, 'partial');
|
|
400
|
+
assert.equal(partialsInHistory(outcome.session), 1);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
await t.test('late positive acknowledgment is awaited before one replacement', async () => {
|
|
405
|
+
const outcome = await runCase({
|
|
406
|
+
reset: async ({ chars, getVisible, setVisible }) => {
|
|
407
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
408
|
+
setVisible(getVisible().slice(0, -chars));
|
|
409
|
+
return true;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
assert.equal(outcome.error, undefined);
|
|
413
|
+
assert.equal(outcome.calls, 2);
|
|
414
|
+
assert.equal(outcome.visible, '');
|
|
415
|
+
assert.equal(outcome.result.content, 'replacement');
|
|
416
|
+
assert.equal(outcome.session.messages.filter((message) => (
|
|
417
|
+
message.role === 'assistant' && message.content === 'replacement'
|
|
418
|
+
)).length, 1);
|
|
419
|
+
assert.equal(partialsInHistory(outcome.session), 0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await t.test('failed restart restores one partial to interruption history', async () => {
|
|
423
|
+
const outcome = await runCase({
|
|
424
|
+
restartFails: true,
|
|
425
|
+
reset: async ({ chars, getVisible, setVisible }) => {
|
|
426
|
+
setVisible(getVisible().slice(0, -chars));
|
|
427
|
+
return true;
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
assert.match(outcome.error?.message || '', /non-streaming restart failed/);
|
|
431
|
+
assert.equal(outcome.calls, 2);
|
|
432
|
+
assert.equal(outcome.visible, '', 'acknowledged consumer keeps the tombstone');
|
|
433
|
+
assert.equal(partialsInHistory(outcome.session), 1, 'history restores exactly one exposed partial');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await t.test('cancellation during restart restores the partial before finalization', async () => {
|
|
437
|
+
const outcome = await runCase({
|
|
438
|
+
cancelRestart: true,
|
|
439
|
+
reset: async ({ chars, getVisible, setVisible }) => {
|
|
440
|
+
setVisible(getVisible().slice(0, -chars));
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
assert.equal(outcome.error?.name, 'SessionClosedError');
|
|
445
|
+
assert.equal(outcome.calls, 2);
|
|
446
|
+
assert.equal(outcome.visible, '', 'acknowledged consumer keeps the tombstone');
|
|
447
|
+
assert.equal(partialsInHistory(outcome.session), 1, 'cancel history preserves exactly one partial');
|
|
448
|
+
});
|
|
449
|
+
} finally {
|
|
450
|
+
if (priorRetries == null) delete process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
451
|
+
else process.env.CLAUDE_CODE_MAX_RETRIES = priorRetries;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
223
455
|
test('OAuth and API-key providers switch to the opt-in fallback after three 529s', async () => {
|
|
224
456
|
const priorRetries = process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
225
457
|
process.env.CLAUDE_CODE_MAX_RETRIES = '2';
|
|
@@ -359,6 +591,34 @@ test('x-should-retry false vetoes retry before status defaults', async () => {
|
|
|
359
591
|
}
|
|
360
592
|
});
|
|
361
593
|
|
|
594
|
+
test('Anthropic x-should-retry true overrides a normally terminal status only for Anthropic', async () => {
|
|
595
|
+
for (const provider of ['anthropic', 'gemini']) {
|
|
596
|
+
let attempts = 0;
|
|
597
|
+
const result = await withRetry(async () => {
|
|
598
|
+
attempts += 1;
|
|
599
|
+
if (attempts === 1) {
|
|
600
|
+
throw Object.assign(new Error('bad request with server override'), {
|
|
601
|
+
status: 400,
|
|
602
|
+
headers: new Map([['x-should-retry', 'true']]),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return 'recovered';
|
|
606
|
+
}, {
|
|
607
|
+
provider,
|
|
608
|
+
maxAttempts: 2,
|
|
609
|
+
backoffMs: [0],
|
|
610
|
+
retryJitterRatio: 0,
|
|
611
|
+
}).then((value) => value, (error) => error);
|
|
612
|
+
if (provider === 'anthropic') {
|
|
613
|
+
assert.equal(result, 'recovered');
|
|
614
|
+
assert.equal(attempts, 2);
|
|
615
|
+
} else {
|
|
616
|
+
assert.equal(result?.status, 400);
|
|
617
|
+
assert.equal(attempts, 1);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
362
622
|
test('Retry-After is not capped or jittered and remains abortable', async () => {
|
|
363
623
|
const ac = new AbortController();
|
|
364
624
|
let observed;
|
|
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, unl
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
_normalizeSummaryIndex,
|
|
@@ -23,9 +24,55 @@ import {
|
|
|
23
24
|
markSessionClosed,
|
|
24
25
|
saveSession,
|
|
25
26
|
saveSessionAsync,
|
|
27
|
+
saveSessionAsyncDeferred,
|
|
26
28
|
setLiveSession,
|
|
27
29
|
} from '../src/runtime/agent/orchestrator/session/store.mjs';
|
|
28
30
|
|
|
31
|
+
test('deferred terminal save is registered for immediate exit drain', async () => {
|
|
32
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-deferred-terminal-'));
|
|
33
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
34
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
35
|
+
try {
|
|
36
|
+
const session = {
|
|
37
|
+
id: 'deferred_terminal_exit',
|
|
38
|
+
owner: 'user',
|
|
39
|
+
createdAt: Date.now(),
|
|
40
|
+
updatedAt: Date.now(),
|
|
41
|
+
messages: [{ role: 'assistant', content: 'terminal state' }],
|
|
42
|
+
};
|
|
43
|
+
const pending = saveSessionAsyncDeferred(session);
|
|
44
|
+
drainSessionStore();
|
|
45
|
+
await pending;
|
|
46
|
+
assert.equal(loadSession(session.id)?.messages?.[0]?.content, 'terminal state');
|
|
47
|
+
} finally {
|
|
48
|
+
drainSessionStore();
|
|
49
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
50
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
51
|
+
rmSync(root, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('exit drain writes deferred terminal state after an older worker-pending save', async () => {
|
|
56
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-deferred-order-'));
|
|
57
|
+
try {
|
|
58
|
+
const storeUrl = new URL('../src/runtime/agent/orchestrator/session/store.mjs', import.meta.url).href;
|
|
59
|
+
const output = execFileSync(process.execPath, ['--input-type=module', '-e', `
|
|
60
|
+
process.env.MIXDOG_DATA_DIR = ${JSON.stringify(root)};
|
|
61
|
+
const { saveSessionAsync, saveSessionAsyncDeferred, drainSessionStore, loadSession } = await import(${JSON.stringify(storeUrl)});
|
|
62
|
+
const base = { id: 'deferred_order_exit', owner: 'user', createdAt: Date.now(), updatedAt: Date.now() };
|
|
63
|
+
saveSessionAsync({ ...base, messages: [{ role: 'assistant', content: 'older worker snapshot' }], padding: 'x'.repeat(2_000_000) }).catch(() => {});
|
|
64
|
+
const terminal = saveSessionAsyncDeferred({ ...base, updatedAt: Date.now() + 1, messages: [{ role: 'assistant', content: 'newest terminal snapshot' }] });
|
|
65
|
+
drainSessionStore();
|
|
66
|
+
await Promise.allSettled([terminal]);
|
|
67
|
+
process.stdout.write(loadSession(base.id)?.messages?.[0]?.content || '');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
`], { encoding: 'utf8' });
|
|
70
|
+
assert.equal(output, 'newest terminal snapshot');
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(root, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
29
76
|
test('desktop classification is optional and round-trips through the existing summary index', () => {
|
|
30
77
|
const task = _sessionSummary({
|
|
31
78
|
id: 'lead_task',
|