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.
Files changed (124) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/bench-run.mjs +2 -2
  5. package/scripts/compact-pressure-test.mjs +104 -0
  6. package/scripts/desktop-session-bridge-test.mjs +704 -0
  7. package/scripts/freevar-smoke.mjs +7 -4
  8. package/scripts/gemini-provider-test.mjs +396 -1
  9. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  10. package/scripts/lifecycle-api-test.mjs +65 -4
  11. package/scripts/max-output-recovery-test.mjs +31 -0
  12. package/scripts/memory-core-input-test.mjs +10 -0
  13. package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
  14. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  15. package/scripts/parent-abort-link-test.mjs +24 -0
  16. package/scripts/process-lifecycle-test.mjs +91 -22
  17. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  18. package/scripts/provider-contract-test.mjs +326 -11
  19. package/scripts/provider-toolcall-test.mjs +269 -27
  20. package/scripts/session-orphan-sweep-test.mjs +27 -1
  21. package/src/lib/keychain-cjs.cjs +36 -23
  22. package/src/repl.mjs +11 -0
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  25. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  26. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
  30. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
  31. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
  34. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
  35. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  36. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  37. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  38. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  46. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  49. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
  50. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  51. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  52. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  53. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  54. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
  55. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  56. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  57. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  59. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  60. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  62. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  63. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  65. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  66. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  68. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  70. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  71. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  73. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  74. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  75. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  76. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/backends/discord.mjs +6 -6
  78. package/src/runtime/channels/lib/config.mjs +15 -2
  79. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  80. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  81. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  82. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  83. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  84. package/src/runtime/memory/index.mjs +24 -198
  85. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  86. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  87. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  89. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  90. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  91. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  92. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  93. package/src/runtime/shared/config.mjs +58 -13
  94. package/src/runtime/shared/llm/cost.mjs +14 -4
  95. package/src/runtime/shared/memory-snapshot.mjs +245 -0
  96. package/src/runtime/shared/process-lifecycle.mjs +184 -34
  97. package/src/runtime/shared/process-shutdown.mjs +6 -0
  98. package/src/runtime/shared/resource-admission.mjs +7 -2
  99. package/src/session-runtime/channel-config-api.mjs +7 -7
  100. package/src/session-runtime/config-lifecycle.mjs +20 -17
  101. package/src/session-runtime/cwd-plugins.mjs +9 -7
  102. package/src/session-runtime/env.mjs +1 -2
  103. package/src/session-runtime/hitch-profile.mjs +45 -0
  104. package/src/session-runtime/lifecycle-api.mjs +36 -9
  105. package/src/session-runtime/mcp-glue.mjs +6 -11
  106. package/src/session-runtime/provider-init-key.mjs +17 -0
  107. package/src/session-runtime/runtime-core.mjs +44 -103
  108. package/src/session-runtime/runtime-paths.mjs +20 -0
  109. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  110. package/src/session-runtime/session-turn-api.mjs +1 -0
  111. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  112. package/src/session-runtime/tool-catalog.mjs +15 -89
  113. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  114. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  115. package/src/standalone/agent-tool.mjs +12 -162
  116. package/src/standalone/channel-admin.mjs +29 -0
  117. package/src/tui/App.jsx +11 -5
  118. package/src/tui/dist/index.mjs +233 -65
  119. package/src/tui/engine/session-api-ext.mjs +37 -4
  120. package/src/tui/engine/turn.mjs +31 -0
  121. package/src/tui/index.jsx +1 -0
  122. package/src/tui/lib/voice-setup.mjs +5 -5
  123. package/scripts/_devtools-stub.mjs +0 -1
  124. 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 SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
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.ok(GEMINI_GLOBAL_CACHE_MIN_LIVE_MS < 5 * 60_000);
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
- flushConfigSave: () => {},
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' }] },