mixdog 0.9.52 → 0.9.54
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 +1 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/bench-run.mjs +2 -2
- package/scripts/compact-pressure-test.mjs +104 -0
- package/scripts/desktop-session-bridge-test.mjs +704 -0
- package/scripts/freevar-smoke.mjs +7 -4
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/lifecycle-api-test.mjs +65 -4
- package/scripts/max-output-recovery-test.mjs +31 -0
- package/scripts/memory-core-input-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
- package/scripts/openai-ws-early-settle-test.mjs +40 -0
- package/scripts/parent-abort-link-test.mjs +24 -0
- package/scripts/process-lifecycle-test.mjs +91 -22
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +326 -11
- package/scripts/provider-toolcall-test.mjs +269 -27
- package/scripts/session-orphan-sweep-test.mjs +27 -1
- package/src/lib/keychain-cjs.cjs +36 -23
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
- package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
- package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
- package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
- 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 +96 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
- package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
- package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +6 -6
- package/src/runtime/channels/lib/config.mjs +15 -2
- package/src/runtime/channels/lib/memory-client.mjs +20 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
- package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
- package/src/runtime/channels/lib/worker-main.mjs +16 -5
- package/src/runtime/memory/index.mjs +24 -198
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
- package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -122
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
- package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
- package/src/runtime/search/lib/http-fetch.mjs +1 -1
- package/src/runtime/shared/config.mjs +58 -13
- package/src/runtime/shared/llm/cost.mjs +14 -4
- package/src/runtime/shared/memory-snapshot.mjs +245 -0
- package/src/runtime/shared/process-lifecycle.mjs +184 -34
- package/src/runtime/shared/process-shutdown.mjs +6 -0
- package/src/runtime/shared/resource-admission.mjs +7 -2
- package/src/session-runtime/channel-config-api.mjs +7 -7
- package/src/session-runtime/config-lifecycle.mjs +20 -17
- package/src/session-runtime/cwd-plugins.mjs +9 -7
- package/src/session-runtime/env.mjs +1 -2
- package/src/session-runtime/hitch-profile.mjs +45 -0
- package/src/session-runtime/lifecycle-api.mjs +36 -9
- package/src/session-runtime/mcp-glue.mjs +6 -11
- package/src/session-runtime/provider-init-key.mjs +17 -0
- package/src/session-runtime/runtime-core.mjs +44 -103
- package/src/session-runtime/runtime-paths.mjs +20 -0
- package/src/session-runtime/runtime-tool-routing.mjs +55 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/tool-catalog-data.mjs +51 -0
- package/src/session-runtime/tool-catalog.mjs +15 -89
- package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
- package/src/standalone/agent-tool/worker-rows.mjs +93 -0
- package/src/standalone/agent-tool.mjs +12 -162
- package/src/standalone/channel-admin.mjs +29 -0
- package/src/tui/App.jsx +11 -5
- package/src/tui/dist/index.mjs +233 -65
- package/src/tui/engine/session-api-ext.mjs +37 -4
- package/src/tui/engine/turn.mjs +31 -0
- package/src/tui/index.jsx +1 -0
- package/src/tui/lib/voice-setup.mjs +5 -5
- package/scripts/_devtools-stub.mjs +0 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -288
|
@@ -20,8 +20,11 @@ import { analyze } from 'eslint-scope';
|
|
|
20
20
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
21
|
// Plain .mjs source roots. src/tui is covered by smoke:tui on the BUILT
|
|
22
22
|
// bundle (App.jsx is JSX — acorn can't parse it raw); vendor is third-party.
|
|
23
|
-
const SCAN_ROOTS = ['src/runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
|
|
24
|
-
const
|
|
23
|
+
const SCAN_ROOTS = ['src/runtime', 'src/session-runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
|
|
24
|
+
const ROOT_RUNTIME_FACADES = readdirSync(join(ROOT, 'src'), { withFileTypes: true })
|
|
25
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.mjs'))
|
|
26
|
+
.map(entry => join(ROOT, 'src', entry.name));
|
|
27
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'vendor', '.git']);
|
|
25
28
|
|
|
26
29
|
const KNOWN_GLOBALS = new Set([
|
|
27
30
|
'Array', 'ArrayBuffer', 'Atomics', 'BigInt', 'BigInt64Array', 'BigUint64Array', 'Boolean',
|
|
@@ -61,9 +64,9 @@ function* walk(dir) {
|
|
|
61
64
|
|
|
62
65
|
let failures = 0;
|
|
63
66
|
let scanned = 0;
|
|
64
|
-
for (const root of SCAN_ROOTS) {
|
|
67
|
+
for (const root of [...SCAN_ROOTS, ...ROOT_RUNTIME_FACADES]) {
|
|
65
68
|
let files;
|
|
66
|
-
try { files = [...walk(root)]; } catch { continue; }
|
|
69
|
+
try { files = statSync(root).isDirectory() ? [...walk(root)] : [root]; } catch { continue; }
|
|
67
70
|
for (const file of files) {
|
|
68
71
|
scanned++;
|
|
69
72
|
const source = readFileSync(file, 'utf8');
|
|
@@ -427,7 +427,7 @@ test('Gemini function-response media is nested, referenced, and MIME-safe', () =
|
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
test('Gemini global cache accepts a fresh default-five-minute entry', () => {
|
|
430
|
-
assert.
|
|
430
|
+
assert.equal(GEMINI_GLOBAL_CACHE_MIN_LIVE_MS, 75_000);
|
|
431
431
|
geminiGlobalCaches.clear();
|
|
432
432
|
const now = Date.now();
|
|
433
433
|
_setGeminiGlobalCache('k', {
|
|
@@ -437,6 +437,16 @@ test('Gemini global cache accepts a fresh default-five-minute entry', () => {
|
|
|
437
437
|
assert.equal(_getGeminiGlobalCache('k', now)?.cacheName, 'cachedContents/fresh');
|
|
438
438
|
});
|
|
439
439
|
|
|
440
|
+
test('Gemini global cache rejects entries inside first-byte headroom', () => {
|
|
441
|
+
geminiGlobalCaches.clear();
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
_setGeminiGlobalCache('near-expiry', {
|
|
444
|
+
cacheName: 'cachedContents/near-expiry',
|
|
445
|
+
cacheExpiresAt: now + 70_000,
|
|
446
|
+
});
|
|
447
|
+
assert.equal(_getGeminiGlobalCache('near-expiry', now), null);
|
|
448
|
+
});
|
|
449
|
+
|
|
440
450
|
test('Gemini cache identity includes toolConfig', () => {
|
|
441
451
|
const base = {
|
|
442
452
|
model: 'gemini-2.5-flash',
|
|
@@ -518,6 +528,47 @@ test('Gemini cachedContents creation carries toolConfig with the tool schema', a
|
|
|
518
528
|
}
|
|
519
529
|
});
|
|
520
530
|
|
|
531
|
+
test('Gemini cachedContents creation retries transient failures within policy', async () => {
|
|
532
|
+
geminiGlobalCaches.clear();
|
|
533
|
+
let calls = 0;
|
|
534
|
+
const provider = new GeminiProvider(hermeticConfig({ fetchFn: async () => {
|
|
535
|
+
calls += 1;
|
|
536
|
+
if (calls === 1) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
status: 503,
|
|
540
|
+
headers: new Headers({ 'Retry-After': '0' }),
|
|
541
|
+
async text() { return '{"error":{"message":"temporarily unavailable"}}'; },
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
async json() {
|
|
547
|
+
return {
|
|
548
|
+
name: 'cachedContents/retried',
|
|
549
|
+
usageMetadata: { totalTokenCount: 3000 },
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
} }));
|
|
554
|
+
try {
|
|
555
|
+
const name = await provider._ensureGeminiCache({
|
|
556
|
+
apiKey: 'test-key',
|
|
557
|
+
model: 'gemini-2.5-flash',
|
|
558
|
+
systemInstruction: 'x'.repeat(9000),
|
|
559
|
+
contents: [
|
|
560
|
+
{ role: 'user', parts: [{ text: 'prefix' }] },
|
|
561
|
+
{ role: 'user', parts: [{ text: 'latest' }] },
|
|
562
|
+
],
|
|
563
|
+
opts: { providerState: {}, iteration: 1 },
|
|
564
|
+
});
|
|
565
|
+
assert.equal(name, 'cachedContents/retried');
|
|
566
|
+
assert.equal(calls, 2);
|
|
567
|
+
} finally {
|
|
568
|
+
geminiGlobalCaches.clear();
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
521
572
|
test('Gemini constructor and send are network-hermetic through injected transports', async () => {
|
|
522
573
|
let preconnectCalls = 0;
|
|
523
574
|
let fetchCalls = 0;
|
|
@@ -849,6 +900,133 @@ test('Gemini REST EOF finalizes buffered leaked tools before classifying truncat
|
|
|
849
900
|
assert.equal(classifyError(error), 'permanent');
|
|
850
901
|
});
|
|
851
902
|
|
|
903
|
+
test('Gemini REST malformed SSE is retryable corruption even before a later STOP', async () => {
|
|
904
|
+
const body = new ReadableStream({
|
|
905
|
+
start(controller) {
|
|
906
|
+
controller.enqueue(new TextEncoder().encode([
|
|
907
|
+
'data: {not-json}',
|
|
908
|
+
'data: {"candidates":[{"finishReason":"STOP"}]}',
|
|
909
|
+
'',
|
|
910
|
+
].join('\n')));
|
|
911
|
+
controller.close();
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
const error = await consumeGeminiRestStreamResponse(
|
|
915
|
+
{ body },
|
|
916
|
+
{ label: 'REST corrupt SSE' },
|
|
917
|
+
).then(() => null, (caught) => caught);
|
|
918
|
+
assert.equal(error.streamCorruption, true);
|
|
919
|
+
assert.equal(error.code, 'TRUNCATED_STREAM');
|
|
920
|
+
assert.equal(classifyError(error), 'transient');
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('Gemini REST already-aborted signal does not lock the response body', async () => {
|
|
924
|
+
const controller = new AbortController();
|
|
925
|
+
controller.abort(new Error('already closed'));
|
|
926
|
+
const body = new ReadableStream({ start() {} });
|
|
927
|
+
await assert.rejects(
|
|
928
|
+
consumeGeminiRestStreamResponse(
|
|
929
|
+
{ body },
|
|
930
|
+
{ label: 'REST pre-abort', signal: controller.signal },
|
|
931
|
+
),
|
|
932
|
+
/already closed/,
|
|
933
|
+
);
|
|
934
|
+
assert.equal(body.locked, false);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test('Gemini SDK first-byte timeout cancels generation before rejecting', async () => {
|
|
938
|
+
let cancelled = false;
|
|
939
|
+
let returned = false;
|
|
940
|
+
const streamResult = {
|
|
941
|
+
stream: {
|
|
942
|
+
[Symbol.asyncIterator]() {
|
|
943
|
+
return {
|
|
944
|
+
next() { return new Promise(() => {}); },
|
|
945
|
+
async return() {
|
|
946
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
947
|
+
returned = true;
|
|
948
|
+
return { done: true };
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
const error = await consumeGeminiSdkStream(streamResult, {
|
|
955
|
+
label: 'SDK timeout cancellation',
|
|
956
|
+
firstByteTimeoutMs: 5,
|
|
957
|
+
cancelGeneration: () => { cancelled = true; },
|
|
958
|
+
}).then(() => null, (caught) => caught);
|
|
959
|
+
assert.equal(error.code, 'EGEMINITIMEOUT');
|
|
960
|
+
assert.equal(cancelled, true);
|
|
961
|
+
assert.equal(returned, true);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('Gemini SDK timeout is bounded when iterator return never settles', async () => {
|
|
965
|
+
let cancelled = false;
|
|
966
|
+
const startedAt = Date.now();
|
|
967
|
+
const streamResult = {
|
|
968
|
+
stream: {
|
|
969
|
+
[Symbol.asyncIterator]() {
|
|
970
|
+
return {
|
|
971
|
+
next() { return new Promise(() => {}); },
|
|
972
|
+
return() { return new Promise(() => {}); },
|
|
973
|
+
};
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
response: new Promise(() => {}),
|
|
977
|
+
};
|
|
978
|
+
const error = await consumeGeminiSdkStream(streamResult, {
|
|
979
|
+
label: 'SDK stuck cancellation',
|
|
980
|
+
firstByteTimeoutMs: 5,
|
|
981
|
+
cancellationGraceMs: 20,
|
|
982
|
+
cancelGeneration: () => { cancelled = true; },
|
|
983
|
+
}).then(() => null, (caught) => caught);
|
|
984
|
+
assert.equal(error.code, 'EGEMINITIMEOUT');
|
|
985
|
+
assert.equal(cancelled, true);
|
|
986
|
+
assert.ok(Date.now() - startedAt < 200, 'stuck iterator.return must not deadlock timeout rejection');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('Gemini SDK parser errors are retryable stream corruption', async () => {
|
|
990
|
+
const parseFailure = Object.assign(
|
|
991
|
+
new Error('[GoogleGenerativeAI Error]: Error parsing JSON response: Unexpected end of JSON input'),
|
|
992
|
+
{ name: 'GoogleGenerativeAIError' },
|
|
993
|
+
);
|
|
994
|
+
const streamResult = {
|
|
995
|
+
stream: {
|
|
996
|
+
[Symbol.asyncIterator]() {
|
|
997
|
+
return {
|
|
998
|
+
async next() { throw parseFailure; },
|
|
999
|
+
async return() { return { done: true }; },
|
|
1000
|
+
};
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
response: Promise.reject(parseFailure),
|
|
1004
|
+
};
|
|
1005
|
+
const error = await consumeGeminiSdkStream(streamResult, {
|
|
1006
|
+
label: 'SDK corrupt SSE',
|
|
1007
|
+
}).then(() => null, (caught) => caught);
|
|
1008
|
+
assert.equal(error.streamCorruption, true);
|
|
1009
|
+
assert.equal(error.code, 'TRUNCATED_STREAM');
|
|
1010
|
+
assert.equal(classifyError(error), 'transient');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test('Gemini native function call is retry-safe until provider dispatch', async () => {
|
|
1014
|
+
const streamResult = {
|
|
1015
|
+
stream: {
|
|
1016
|
+
async *[Symbol.asyncIterator]() {
|
|
1017
|
+
yield { candidates: [{ content: { parts: [{ functionCall: { name: 'read', args: {} } }] } }] };
|
|
1018
|
+
throw Object.assign(new Error('socket reset'), { code: 'ECONNRESET' });
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
const error = await consumeGeminiSdkStream(streamResult, {
|
|
1023
|
+
label: 'SDK pre-dispatch function call',
|
|
1024
|
+
}).then(() => null, (caught) => caught);
|
|
1025
|
+
assert.equal(error.emittedToolCall, undefined);
|
|
1026
|
+
assert.equal(error.unsafeToRetry, undefined);
|
|
1027
|
+
assert.equal(classifyError(error), 'transient');
|
|
1028
|
+
});
|
|
1029
|
+
|
|
852
1030
|
test('Gemini SDK failure finalizes buffered leaked tools before retry-safety stamps', async () => {
|
|
853
1031
|
const streamResult = {
|
|
854
1032
|
stream: {
|
|
@@ -1051,3 +1229,220 @@ test('Gemini auth reload retries a status-coded 401 exactly once', async () => {
|
|
|
1051
1229
|
assert.equal(reloads, 1);
|
|
1052
1230
|
assert.equal(opts.providerState.gemini, undefined);
|
|
1053
1231
|
});
|
|
1232
|
+
|
|
1233
|
+
test('Gemini evicted cachedContent is invalidated and retried uncached', async () => {
|
|
1234
|
+
geminiGlobalCaches.clear();
|
|
1235
|
+
_setGeminiGlobalCache('evicted-key', {
|
|
1236
|
+
cacheName: 'cachedContents/evicted',
|
|
1237
|
+
cacheExpiresAt: Date.now() + 5 * 60_000,
|
|
1238
|
+
});
|
|
1239
|
+
let cacheChecks = 0;
|
|
1240
|
+
let restCalls = 0;
|
|
1241
|
+
let sdkCalls = 0;
|
|
1242
|
+
const provider = new GeminiProvider(hermeticConfig({
|
|
1243
|
+
fetchFn: async () => {
|
|
1244
|
+
restCalls += 1;
|
|
1245
|
+
return {
|
|
1246
|
+
ok: false,
|
|
1247
|
+
status: 404,
|
|
1248
|
+
headers: new Headers(),
|
|
1249
|
+
async text() {
|
|
1250
|
+
return JSON.stringify({ error: { message: 'Cached content not found', status: 'NOT_FOUND' } });
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
},
|
|
1254
|
+
genAI: {
|
|
1255
|
+
getGenerativeModel() {
|
|
1256
|
+
return {
|
|
1257
|
+
async generateContentStream() {
|
|
1258
|
+
sdkCalls += 1;
|
|
1259
|
+
return sdkStream([{
|
|
1260
|
+
candidates: [{ content: { role: 'model', parts: [{ text: 'uncached ok' }] }, finishReason: 'STOP' }],
|
|
1261
|
+
}]);
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
}));
|
|
1267
|
+
provider._ensureGeminiCache = async ({ skipExplicitCache }) => {
|
|
1268
|
+
cacheChecks += 1;
|
|
1269
|
+
return skipExplicitCache ? null : 'cachedContents/evicted';
|
|
1270
|
+
};
|
|
1271
|
+
const opts = {
|
|
1272
|
+
providerState: { gemini: { cacheName: 'cachedContents/evicted', cachePrefixContentCount: 0 } },
|
|
1273
|
+
};
|
|
1274
|
+
const result = await provider.send(
|
|
1275
|
+
[{ role: 'user', content: 'hello' }],
|
|
1276
|
+
'gemini-2.5-flash',
|
|
1277
|
+
[],
|
|
1278
|
+
opts,
|
|
1279
|
+
);
|
|
1280
|
+
assert.equal(result.content, 'uncached ok');
|
|
1281
|
+
assert.equal(restCalls, 1);
|
|
1282
|
+
assert.equal(sdkCalls, 1);
|
|
1283
|
+
assert.equal(cacheChecks, 2);
|
|
1284
|
+
assert.equal(opts.providerState.gemini, undefined);
|
|
1285
|
+
assert.equal(geminiGlobalCaches.size, 0);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
test('Gemini REST rate-limit response preserves Retry-After for request retry', async () => {
|
|
1289
|
+
let calls = 0;
|
|
1290
|
+
let firstCallAt = 0;
|
|
1291
|
+
let secondCallAt = 0;
|
|
1292
|
+
const provider = new GeminiProvider(hermeticConfig({
|
|
1293
|
+
fetchFn: async () => {
|
|
1294
|
+
calls += 1;
|
|
1295
|
+
if (calls === 1) {
|
|
1296
|
+
firstCallAt = Date.now();
|
|
1297
|
+
return {
|
|
1298
|
+
ok: false,
|
|
1299
|
+
status: 429,
|
|
1300
|
+
headers: new Headers({ 'Retry-After': '0.05' }),
|
|
1301
|
+
async text() {
|
|
1302
|
+
return JSON.stringify({
|
|
1303
|
+
error: {
|
|
1304
|
+
status: 'RESOURCE_EXHAUSTED',
|
|
1305
|
+
message: 'resource exhausted; retry after the capacity window',
|
|
1306
|
+
details: [{
|
|
1307
|
+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
|
1308
|
+
retryDelay: '0s',
|
|
1309
|
+
}],
|
|
1310
|
+
},
|
|
1311
|
+
});
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
secondCallAt = Date.now();
|
|
1316
|
+
return {
|
|
1317
|
+
ok: true,
|
|
1318
|
+
body: new ReadableStream({
|
|
1319
|
+
start(controller) {
|
|
1320
|
+
controller.enqueue(new TextEncoder().encode(
|
|
1321
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"retried"}]},"finishReason":"STOP"}]}\n',
|
|
1322
|
+
));
|
|
1323
|
+
controller.close();
|
|
1324
|
+
},
|
|
1325
|
+
}),
|
|
1326
|
+
};
|
|
1327
|
+
},
|
|
1328
|
+
}));
|
|
1329
|
+
provider._ensureGeminiCache = async () => 'cachedContents/live';
|
|
1330
|
+
const result = await provider.send(
|
|
1331
|
+
[{ role: 'user', content: 'hello' }],
|
|
1332
|
+
'gemini-2.5-flash',
|
|
1333
|
+
[],
|
|
1334
|
+
{ providerState: { gemini: { cachePrefixContentCount: 0 } } },
|
|
1335
|
+
);
|
|
1336
|
+
assert.equal(result.content, 'retried');
|
|
1337
|
+
assert.equal(calls, 2);
|
|
1338
|
+
assert.ok(secondCallAt - firstCallAt >= 40, 'Retry-After must set the retry delay');
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test('Gemini structured RetryInfo outranks resource-exhausted permanence', async () => {
|
|
1342
|
+
let calls = 0;
|
|
1343
|
+
let firstCallAt = 0;
|
|
1344
|
+
let secondCallAt = 0;
|
|
1345
|
+
const provider = new GeminiProvider(hermeticConfig({
|
|
1346
|
+
fetchFn: async () => {
|
|
1347
|
+
calls += 1;
|
|
1348
|
+
if (calls === 1) {
|
|
1349
|
+
firstCallAt = Date.now();
|
|
1350
|
+
return {
|
|
1351
|
+
ok: false,
|
|
1352
|
+
status: 429,
|
|
1353
|
+
headers: new Headers(),
|
|
1354
|
+
async text() {
|
|
1355
|
+
return JSON.stringify({
|
|
1356
|
+
error: {
|
|
1357
|
+
status: 'RESOURCE_EXHAUSTED',
|
|
1358
|
+
message: 'resource exhausted',
|
|
1359
|
+
details: [{
|
|
1360
|
+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
|
1361
|
+
retryDelay: '0.05s',
|
|
1362
|
+
}],
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
secondCallAt = Date.now();
|
|
1369
|
+
return {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
body: new ReadableStream({
|
|
1372
|
+
start(controller) {
|
|
1373
|
+
controller.enqueue(new TextEncoder().encode(
|
|
1374
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"retry-info"}]},"finishReason":"STOP"}]}\n',
|
|
1375
|
+
));
|
|
1376
|
+
controller.close();
|
|
1377
|
+
},
|
|
1378
|
+
}),
|
|
1379
|
+
};
|
|
1380
|
+
},
|
|
1381
|
+
}));
|
|
1382
|
+
provider._ensureGeminiCache = async () => 'cachedContents/live';
|
|
1383
|
+
const result = await provider.send(
|
|
1384
|
+
[{ role: 'user', content: 'hello' }],
|
|
1385
|
+
'gemini-2.5-flash',
|
|
1386
|
+
[],
|
|
1387
|
+
{ providerState: { gemini: { cachePrefixContentCount: 0 } } },
|
|
1388
|
+
);
|
|
1389
|
+
assert.equal(result.content, 'retry-info');
|
|
1390
|
+
assert.equal(calls, 2);
|
|
1391
|
+
assert.ok(secondCallAt - firstCallAt >= 40, 'RetryInfo.retryDelay must set the retry delay');
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test('Gemini RESOURCE_EXHAUSTED without Retry-After remains permanent', async () => {
|
|
1395
|
+
let calls = 0;
|
|
1396
|
+
const provider = new GeminiProvider(hermeticConfig({
|
|
1397
|
+
fetchFn: async () => {
|
|
1398
|
+
calls += 1;
|
|
1399
|
+
return {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
status: 429,
|
|
1402
|
+
headers: new Headers(),
|
|
1403
|
+
async text() {
|
|
1404
|
+
return JSON.stringify({
|
|
1405
|
+
error: {
|
|
1406
|
+
status: 'RESOURCE_EXHAUSTED',
|
|
1407
|
+
message: 'capacity quota exhausted',
|
|
1408
|
+
details: [{ '@type': 'type.googleapis.com/google.rpc.QuotaFailure' }],
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
},
|
|
1412
|
+
};
|
|
1413
|
+
},
|
|
1414
|
+
}));
|
|
1415
|
+
provider._ensureGeminiCache = async () => 'cachedContents/live';
|
|
1416
|
+
const error = await provider.send(
|
|
1417
|
+
[{ role: 'user', content: 'hello' }],
|
|
1418
|
+
'gemini-2.5-flash',
|
|
1419
|
+
[],
|
|
1420
|
+
{ providerState: { gemini: { cachePrefixContentCount: 0 } } },
|
|
1421
|
+
).then(() => null, (caught) => caught);
|
|
1422
|
+
assert.equal(calls, 1);
|
|
1423
|
+
assert.equal(error.code, 'RESOURCE_EXHAUSTED');
|
|
1424
|
+
assert.equal(error.geminiStatus, 'RESOURCE_EXHAUSTED');
|
|
1425
|
+
assert.equal(error.details[0]['@type'], 'type.googleapis.com/google.rpc.QuotaFailure');
|
|
1426
|
+
assert.equal(classifyError(error), 'permanent');
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
test('Gemini availability probe rejects an unresolved generation within its bound', async () => {
|
|
1430
|
+
let receivedSignal = null;
|
|
1431
|
+
const startedAt = Date.now();
|
|
1432
|
+
const provider = new GeminiProvider(hermeticConfig({
|
|
1433
|
+
genAI: {
|
|
1434
|
+
getGenerativeModel() {
|
|
1435
|
+
return {
|
|
1436
|
+
async generateContent(_input, options) {
|
|
1437
|
+
receivedSignal = options?.signal;
|
|
1438
|
+
return new Promise(() => {});
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
}));
|
|
1444
|
+
assert.equal(await provider.isAvailable(), false);
|
|
1445
|
+
assert.equal(receivedSignal instanceof AbortSignal, true);
|
|
1446
|
+
assert.equal(receivedSignal.aborted, true);
|
|
1447
|
+
assert.ok(Date.now() - startedAt < 1_500, 'availability probe must be bounded');
|
|
1448
|
+
});
|
|
@@ -26,12 +26,13 @@ after(() => {
|
|
|
26
26
|
rmSync(root, { recursive: true, force: true });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
function writeTokens(accessToken, refreshToken, expiresAt) {
|
|
29
|
+
function writeTokens(accessToken, refreshToken, expiresAt, extra = {}) {
|
|
30
30
|
writeJsonAtomicSync(tokenPath, {
|
|
31
31
|
access_token: accessToken,
|
|
32
32
|
refresh_token: refreshToken,
|
|
33
33
|
expires_at: expiresAt,
|
|
34
34
|
token_endpoint: 'https://auth.x.ai/oauth/token',
|
|
35
|
+
...extra,
|
|
35
36
|
}, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -196,3 +197,77 @@ writeFileSync(process.env.RESULT_PATH, JSON.stringify({
|
|
|
196
197
|
assert.deepEqual(JSON.parse(readFileSync(resultPath, 'utf8')), { adopted: true, calls: 1 });
|
|
197
198
|
assert.ok(statSync(tokenPath).size > 0);
|
|
198
199
|
});
|
|
200
|
+
|
|
201
|
+
test('refresh retries transient responses three times, preserves principal fields, and stops on invalid_client', async () => {
|
|
202
|
+
const resultPath = join(root, 'retry-policy-result');
|
|
203
|
+
writeTokens('fixture-retry-access', 'fixture-retry-refresh', Date.now() + 1_000, {
|
|
204
|
+
principal_type: 'user',
|
|
205
|
+
principal_id: 'principal-123',
|
|
206
|
+
});
|
|
207
|
+
const childSource = `
|
|
208
|
+
const { writeFileSync } = await import('node:fs');
|
|
209
|
+
const { GrokOAuthProvider } = await import(${JSON.stringify(moduleUrl)});
|
|
210
|
+
const provider = new GrokOAuthProvider({preconnect:false});
|
|
211
|
+
const bodies = [];
|
|
212
|
+
let retryCalls = 0;
|
|
213
|
+
globalThis.fetch = async (_url, init) => {
|
|
214
|
+
retryCalls += 1;
|
|
215
|
+
bodies.push(Object.fromEntries(init.body.entries()));
|
|
216
|
+
if (retryCalls === 1) {
|
|
217
|
+
return {ok:false,status:400,async text(){return JSON.stringify({error:'temporarily_unavailable'});}};
|
|
218
|
+
}
|
|
219
|
+
if (retryCalls === 2) {
|
|
220
|
+
return {ok:false,status:401,async text(){return '{}';}};
|
|
221
|
+
}
|
|
222
|
+
return {ok:true,status:200,async text(){return JSON.stringify({
|
|
223
|
+
access_token:'fixture-retry-new-access',
|
|
224
|
+
refresh_token:'fixture-retry-new-refresh',
|
|
225
|
+
expires_in:600
|
|
226
|
+
});}};
|
|
227
|
+
};
|
|
228
|
+
const refreshed = await provider.ensureAuth({forceRefresh:true});
|
|
229
|
+
let terminalCalls = 0;
|
|
230
|
+
globalThis.fetch = async () => {
|
|
231
|
+
terminalCalls += 1;
|
|
232
|
+
return {ok:false,status:400,async text(){return JSON.stringify({error:'invalid_client'});}};
|
|
233
|
+
};
|
|
234
|
+
let terminalCode = null;
|
|
235
|
+
try {
|
|
236
|
+
await provider.ensureAuth({forceRefresh:true});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
terminalCode = error.oauthError;
|
|
239
|
+
}
|
|
240
|
+
writeFileSync(process.env.RESULT_PATH, JSON.stringify({
|
|
241
|
+
retryCalls,
|
|
242
|
+
bodies,
|
|
243
|
+
principalType: refreshed.principal_type,
|
|
244
|
+
principalId: refreshed.principal_id,
|
|
245
|
+
terminalCalls,
|
|
246
|
+
terminalCode
|
|
247
|
+
}));
|
|
248
|
+
`;
|
|
249
|
+
const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], {
|
|
250
|
+
env: {
|
|
251
|
+
...process.env,
|
|
252
|
+
MIXDOG_DATA_DIR: root,
|
|
253
|
+
GROK_OAUTH_CREDENTIALS_PATH: tokenPath,
|
|
254
|
+
RESULT_PATH: resultPath,
|
|
255
|
+
},
|
|
256
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
257
|
+
});
|
|
258
|
+
await waitForExit(child, 'refresh retry policy child');
|
|
259
|
+
const result = JSON.parse(readFileSync(resultPath, 'utf8'));
|
|
260
|
+
assert.equal(result.retryCalls, 3);
|
|
261
|
+
assert.equal(result.terminalCalls, 1);
|
|
262
|
+
assert.equal(result.terminalCode, 'invalid_client');
|
|
263
|
+
assert.equal(result.principalType, 'user');
|
|
264
|
+
assert.equal(result.principalId, 'principal-123');
|
|
265
|
+
for (const body of result.bodies) {
|
|
266
|
+
assert.equal(body.principal_type, 'user');
|
|
267
|
+
assert.equal(body.principal_id, 'principal-123');
|
|
268
|
+
assert.equal(body.refresh_token, 'fixture-retry-refresh');
|
|
269
|
+
}
|
|
270
|
+
const persisted = JSON.parse(readFileSync(tokenPath, 'utf8'));
|
|
271
|
+
assert.equal(persisted.principal_type, 'user');
|
|
272
|
+
assert.equal(persisted.principal_id, 'principal-123');
|
|
273
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import test from 'node:test';
|
|
3
6
|
|
|
7
|
+
import { createConfigLifecycle } from '../src/session-runtime/config-lifecycle.mjs';
|
|
4
8
|
import { createLifecycleApi } from '../src/session-runtime/lifecycle-api.mjs';
|
|
5
9
|
import {
|
|
6
10
|
_clearWebSocketPoolForTest,
|
|
@@ -17,7 +21,7 @@ function socket() {
|
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
function lifecycleFor(session) {
|
|
24
|
+
function lifecycleFor(session, overrides = {}) {
|
|
21
25
|
let current = session;
|
|
22
26
|
return createLifecycleApi({
|
|
23
27
|
getSession: () => current,
|
|
@@ -45,15 +49,14 @@ function lifecycleFor(session) {
|
|
|
45
49
|
mcpClient: { disconnectAll: () => null },
|
|
46
50
|
warmupTimers: {},
|
|
47
51
|
prewarmTimers: {},
|
|
48
|
-
|
|
49
|
-
flushBackendSave: () => {},
|
|
50
|
-
flushOutputStyleSave: () => {},
|
|
52
|
+
flushAllConfigSavesAsync: async () => {},
|
|
51
53
|
withTeardownDeadline: (promise) => promise,
|
|
52
54
|
closePatchRuntimeIfLoaded: () => null,
|
|
53
55
|
invalidateContextStatusCache: () => {},
|
|
54
56
|
invalidatePreSessionToolSurface: () => {},
|
|
55
57
|
notificationListeners: { clear: () => {} },
|
|
56
58
|
remoteStateListeners: { clear: () => {} },
|
|
59
|
+
...overrides,
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -74,3 +77,61 @@ test('lifecycle drains the OpenAI WS pool only for process exit', async () => {
|
|
|
74
77
|
|
|
75
78
|
assert.deepEqual(retainedSocket.closed, ['cli-exit']);
|
|
76
79
|
});
|
|
80
|
+
|
|
81
|
+
test('lifecycle barrier drains a direct updateSectionAsync with no queued lifecycle save', async () => {
|
|
82
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-lifecycle-config-'));
|
|
83
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
84
|
+
const previousBackupRoot = process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
|
|
85
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
86
|
+
process.env.MIXDOG_USER_DATA_BACKUP_ROOT = join(dataDir, 'backups');
|
|
87
|
+
const sharedCfgMod = await import(`../src/runtime/shared/config.mjs?lifecycle-tail=${Date.now()}`);
|
|
88
|
+
const configLifecycle = createConfigLifecycle({
|
|
89
|
+
getConfig: () => ({}),
|
|
90
|
+
setConfig: () => {},
|
|
91
|
+
getSearchRoute: () => null,
|
|
92
|
+
setSearchRoute: () => {},
|
|
93
|
+
getConfigHasSecrets: () => false,
|
|
94
|
+
setConfigHasSecrets: () => {},
|
|
95
|
+
getRoute: () => ({}),
|
|
96
|
+
cfgMod: {
|
|
97
|
+
saveConfigAsync: async () => {},
|
|
98
|
+
patchSkillsDisabledAsync: async () => {},
|
|
99
|
+
getPluginData: () => dataDir,
|
|
100
|
+
},
|
|
101
|
+
sharedCfgMod,
|
|
102
|
+
setBackendAsync: async () => {},
|
|
103
|
+
setConfiguredShell: () => {},
|
|
104
|
+
normalizeSystemShellConfig: () => ({ command: '' }),
|
|
105
|
+
normalizeSearchRouteConfig: () => null,
|
|
106
|
+
outputStyleStatus: () => ({}),
|
|
107
|
+
LAZY_SECRET_PROVIDERS: new Set(),
|
|
108
|
+
clean: (value) => String(value || ''),
|
|
109
|
+
resolve: (value) => value,
|
|
110
|
+
STANDALONE_DATA_DIR: dataDir,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const events = [];
|
|
114
|
+
let directSettled = false;
|
|
115
|
+
try {
|
|
116
|
+
const directWrite = sharedCfgMod.updateSectionAsync('cycle3', () => ({ value: 'direct' }))
|
|
117
|
+
.finally(() => { directSettled = true; events.push('direct:settled'); });
|
|
118
|
+
assert.equal(directSettled, false);
|
|
119
|
+
|
|
120
|
+
const lifecycle = lifecycleFor(
|
|
121
|
+
{ id: 'tail-drain', messages: [], liveTurnMessages: [] },
|
|
122
|
+
{
|
|
123
|
+
flushAllConfigSavesAsync: configLifecycle.flushAllConfigSavesAsync,
|
|
124
|
+
closePatchRuntimeIfLoaded: async () => { events.push('teardown:continued'); },
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
await lifecycle.close('engine-replace');
|
|
128
|
+
await directWrite;
|
|
129
|
+
assert.deepEqual(events, ['direct:settled', 'teardown:continued']);
|
|
130
|
+
} finally {
|
|
131
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
132
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
133
|
+
if (previousBackupRoot == null) delete process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
|
|
134
|
+
else process.env.MIXDOG_USER_DATA_BACKUP_ROOT = previousBackupRoot;
|
|
135
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -118,6 +118,37 @@ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery
|
|
|
118
118
|
assert.equal(provider.sent[1][1].content, 'gemini ');
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
test('Gemini MAX_TOKENS recovery derives missing prompt usage from total minus output', async () => {
|
|
122
|
+
const incomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
|
|
123
|
+
code: 'PROVIDER_INCOMPLETE',
|
|
124
|
+
providerIncomplete: true,
|
|
125
|
+
finishReason: 'MAX_TOKENS',
|
|
126
|
+
partialContent: 'gemini ',
|
|
127
|
+
rawUsage: {
|
|
128
|
+
total_token_count: 20,
|
|
129
|
+
candidates_token_count: 4,
|
|
130
|
+
thoughts_token_count: 1,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const result = await run(queuedProvider([
|
|
134
|
+
incomplete,
|
|
135
|
+
{
|
|
136
|
+
content: 'complete',
|
|
137
|
+
stopReason: 'end_turn',
|
|
138
|
+
usage: { inputTokens: 1, outputTokens: 2, promptTokens: 1 },
|
|
139
|
+
},
|
|
140
|
+
]), undefined, { onTextDelta: () => {} });
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
{
|
|
144
|
+
inputTokens: result.usage.inputTokens,
|
|
145
|
+
outputTokens: result.usage.outputTokens,
|
|
146
|
+
promptTokens: result.usage.promptTokens,
|
|
147
|
+
},
|
|
148
|
+
{ inputTokens: 16, outputTokens: 7, promptTokens: 16 },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
test('Gemini MAX_TOKENS continuation preserves provider-scoped replay metadata', async () => {
|
|
122
153
|
const providerMetadata = {
|
|
123
154
|
gemini: { thoughtParts: [{ text: 'signed thought', thoughtSignature: 'sig-gemini' }] },
|