@bitkyc08/opencodex 2.33.0 → 2.34.0

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 (159) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-C4TMRloX.js +112 -0
  3. package/gui/dist/assets/index-DQ-Ie18T.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  17. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  18. package/src/adapters/cursor/native-exec-network.ts +1 -1
  19. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  20. package/src/adapters/cursor/native-exec.ts +20 -0
  21. package/src/adapters/cursor/protobuf-request.ts +68 -14
  22. package/src/adapters/cursor/request-builder.ts +29 -6
  23. package/src/adapters/cursor/tool-definitions.ts +11 -2
  24. package/src/adapters/cursor/tool-result-normalize.ts +32 -1
  25. package/src/adapters/cursor/types.ts +20 -0
  26. package/src/adapters/cursor.ts +189 -37
  27. package/src/adapters/google-antigravity-replay.ts +102 -23
  28. package/src/adapters/google-errors.ts +44 -12
  29. package/src/adapters/google.ts +115 -16
  30. package/src/adapters/image.ts +1 -1
  31. package/src/adapters/kiro-constants.ts +15 -0
  32. package/src/adapters/kiro-tools.ts +43 -15
  33. package/src/adapters/kiro.ts +54 -9
  34. package/src/adapters/openai-chat.ts +286 -242
  35. package/src/adapters/openai-responses.ts +334 -24
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -24
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +32 -4
  44. package/src/cli/account-extended.ts +1 -1
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/init.ts +1 -0
  51. package/src/cli/models-runtime.ts +95 -0
  52. package/src/cli/models.ts +13 -7
  53. package/src/cli/provider-runtime.ts +16 -2
  54. package/src/cli/registry.ts +6 -1
  55. package/src/cli/v2.ts +34 -10
  56. package/src/codex/account-pause.ts +2 -1
  57. package/src/codex/account-priority.ts +3 -2
  58. package/src/codex/app-server-processes.ts +80 -6
  59. package/src/codex/auth-api.ts +46 -8
  60. package/src/codex/auth-context.ts +21 -18
  61. package/src/codex/catalog/aggregation.ts +6 -0
  62. package/src/codex/catalog/native-models.ts +5 -2
  63. package/src/codex/catalog/parsing.ts +16 -0
  64. package/src/codex/catalog/provider-fetch.ts +20 -3
  65. package/src/codex/catalog/sync.ts +127 -2
  66. package/src/codex/catalog.ts +1 -1
  67. package/src/codex/codex-write-lock.ts +3 -1
  68. package/src/codex/convergence-types.ts +1 -1
  69. package/src/codex/convergence.ts +22 -2
  70. package/src/codex/desired-state.ts +2 -2
  71. package/src/codex/desktop-app-restart.ts +18 -5
  72. package/src/codex/inject-coordination.ts +83 -0
  73. package/src/codex/inject.ts +14 -1
  74. package/src/codex/log-guard/inspect.ts +22 -4
  75. package/src/codex/model-entitlements.ts +9 -2
  76. package/src/codex/prompt-layers.ts +371 -25
  77. package/src/codex/prompt-text-probe.ts +238 -0
  78. package/src/codex/quota.ts +91 -10
  79. package/src/codex/routing.ts +9 -0
  80. package/src/codex/subagent-model-fallback.ts +104 -25
  81. package/src/codex/transition-state.ts +107 -8
  82. package/src/combos/types.ts +10 -0
  83. package/src/compatibility/openai-responses.ts +33 -1
  84. package/src/config/rebase-provenance.ts +68 -0
  85. package/src/config.ts +107 -9
  86. package/src/generated/compatibility-version.json +191 -139
  87. package/src/images/loop.ts +5 -4
  88. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  89. package/src/lab/fabric/producer-child.ts +1 -1
  90. package/src/lib/config-ownership.ts +20 -0
  91. package/src/lib/errors.ts +11 -2
  92. package/src/lib/package-tree-integrity.ts +101 -0
  93. package/src/oauth/generic-account-failover.ts +231 -0
  94. package/src/oauth/index.ts +57 -5
  95. package/src/oauth/key-providers.ts +10 -1
  96. package/src/oauth/kiro.ts +45 -0
  97. package/src/oauth/types.ts +15 -0
  98. package/src/providers/command-code-efforts.ts +38 -6
  99. package/src/providers/context-cap.ts +4 -3
  100. package/src/providers/default-aliases.ts +65 -0
  101. package/src/providers/derive.ts +28 -0
  102. package/src/providers/fastwire.ts +7 -1
  103. package/src/providers/model-presets.ts +119 -0
  104. package/src/providers/new-model-policy.ts +146 -0
  105. package/src/providers/provider-id-rewrite.ts +2 -1
  106. package/src/providers/quota.ts +65 -7
  107. package/src/providers/registry.ts +160 -68
  108. package/src/providers/slug-codec.ts +52 -0
  109. package/src/responses/code-mode-helper-compat.ts +50 -0
  110. package/src/responses/custom-tool-compat.ts +34 -10
  111. package/src/responses/parser.ts +4 -0
  112. package/src/responses/schema.ts +5 -1
  113. package/src/responses/thought-signature-replay.ts +17 -0
  114. package/src/router.ts +40 -2
  115. package/src/routing/analytics.ts +1 -0
  116. package/src/routing/quota.ts +10 -0
  117. package/src/server/auth-cors.ts +4 -0
  118. package/src/server/chat-completions.ts +24 -16
  119. package/src/server/chat-native-sse.ts +3 -3
  120. package/src/server/chat-native.ts +30 -11
  121. package/src/server/claude-messages.ts +1 -1
  122. package/src/server/index.ts +77 -11
  123. package/src/server/lifecycle.ts +52 -1
  124. package/src/server/management/agent-settings-routes.ts +31 -15
  125. package/src/server/management/codex-prompt-routes.ts +570 -0
  126. package/src/server/management/combo-routes.ts +2 -1
  127. package/src/server/management/config-routes.ts +27 -9
  128. package/src/server/management/context.ts +9 -0
  129. package/src/server/management/model-routes.ts +266 -0
  130. package/src/server/management/provider-routes.ts +6 -0
  131. package/src/server/management/routing-profile-routes.ts +2 -2
  132. package/src/server/management-api.ts +2 -0
  133. package/src/server/relay-eager.ts +147 -20
  134. package/src/server/relay.ts +251 -19
  135. package/src/server/request-log-conversation.ts +21 -0
  136. package/src/server/request-log.ts +41 -15
  137. package/src/server/responses/collaboration.ts +37 -3
  138. package/src/server/responses/combo-stream-preflight.ts +10 -3
  139. package/src/server/responses/core.ts +494 -114
  140. package/src/server/responses/empty-completion-guard.ts +35 -0
  141. package/src/server/responses/fetch-helpers.ts +2 -1
  142. package/src/server/responses/input-admission.ts +3 -1
  143. package/src/server/responses/passthrough-error.ts +33 -9
  144. package/src/server/responses/responses-field-backfill.ts +105 -13
  145. package/src/server/responses-custom-tool-repair.ts +52 -7
  146. package/src/server/responses-terminal-repair.ts +25 -4
  147. package/src/server/sse-frame-buffer.ts +31 -4
  148. package/src/server/ws-bridge.ts +14 -2
  149. package/src/types/config.ts +53 -0
  150. package/src/types/provider.ts +64 -0
  151. package/src/types/request.ts +11 -3
  152. package/src/types/tools.ts +7 -5
  153. package/src/types.ts +1 -0
  154. package/src/usage/expected-prices.ts +43 -24
  155. package/src/usage/log.ts +3 -0
  156. package/src/web-search/exa-executor.ts +40 -9
  157. package/src/web-search/loop.ts +5 -4
  158. package/gui/dist/assets/index-23-Lf7jR.js +0 -102
  159. package/gui/dist/assets/index-DxJMDyOr.css +0 -1
@@ -9,6 +9,7 @@ import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
9
9
  import { mapCursorServerMessage } from "./cursor/message-mapper";
10
10
  import {
11
11
  createCursorRequest,
12
+ cursorClientThreadOwner,
12
13
  cursorCoveredPrefixDigest,
13
14
  cursorInstructionDigest,
14
15
  } from "./cursor/request-builder";
@@ -28,6 +29,15 @@ import { debugProviderDiagnostic } from "../lib/debug";
28
29
  import { estimateTokens } from "../lib/token-estimate";
29
30
  import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
30
31
  import { runCursorTurnWithRetry } from "./cursor/transport-retry";
32
+ import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions";
33
+ import {
34
+ CURSOR_ECHO_RETRY_CONTINUATION_TEXT,
35
+ CURSOR_ROUTING_COMMENTARY_RETRY_TEXT,
36
+ CursorEnvelopeEchoSniffer,
37
+ CursorRoutingCommentaryError,
38
+ CursorRoutingCommentarySniffer,
39
+ CursorToolResultEchoError,
40
+ } from "./cursor/envelope-echo";
31
41
  import {
32
42
  createDisabledCursorTransport,
33
43
  CursorTransportDisabledError,
@@ -145,15 +155,36 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
145
155
  let completedNormally = false;
146
156
  let lastTransport: { captured?: Uint8Array } | undefined;
147
157
  let emittedClientTool = false;
158
+ // Ordering proof for tool-suspended checkpoints: true only when the newest captured
159
+ // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
160
+ // serialized its suspended-on-tool-call state. Only that snapshot can safely resume
161
+ // with the covered-prefix + trailing-toolResult path (devlog 260826 050).
162
+ let capturedAfterClientTool = false;
148
163
 
149
164
  const commitCapturedCheckpoint = (activeRequest: ReturnType<typeof createCursorRequest>): void => {
165
+ const toolSuspendedCommit =
166
+ emittedClientTool
167
+ && capturedAfterClientTool
168
+ && isCursorExternalWireModel(activeRequest.modelId);
150
169
  if (
151
170
  replayUnsafe
152
- || emittedClientTool
171
+ || (emittedClientTool && !toolSuspendedCommit)
153
172
  || activeRequest.contextUsageStoreCheckpoints === false
154
173
  || !lastTransport?.captured
155
174
  || lastTransport.captured.byteLength === 0
156
- ) return;
175
+ ) {
176
+ // Refusal diagnostics (devlog 260826 050/080): name the exact guard so a live
177
+ // missing_ref chain can be attributed without instrumented rebuilds.
178
+ debugProviderDiagnostic("cursor", "checkpoint-commit-refused", {
179
+ replayUnsafe,
180
+ emittedClientTool,
181
+ capturedAfterClientTool,
182
+ externalModel: isCursorExternalWireModel(activeRequest.modelId),
183
+ storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false,
184
+ capturedBytes: lastTransport?.captured?.byteLength ?? 0,
185
+ });
186
+ return;
187
+ }
157
188
  const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef;
158
189
  const coveredMessageCount = _parsed.context.messages.length;
159
190
  const checkpointRef = commitCursorCheckpoint({
@@ -172,7 +203,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
172
203
  cursor: {
173
204
  ...(_parsed._providerContinuation?.cursor ?? {}),
174
205
  conversationId: activeRequest.conversationId,
175
- checkpointUsable: true,
206
+ // A tool-suspended checkpoint is only usable by the immediate trailing-toolResult
207
+ // continuation; the request-builder guard keys on checkpointUsable=false for that.
208
+ checkpointUsable: !toolSuspendedCommit,
176
209
  checkpointRef,
177
210
  },
178
211
  };
@@ -182,10 +215,43 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
182
215
  checkpointRefHash: cursorCheckpointRefHash(checkpointRef),
183
216
  checkpointBytes: lastTransport.captured.byteLength,
184
217
  wireModel: activeRequest.modelId,
218
+ ...(toolSuspendedCommit ? { toolSuspended: true } : {}),
185
219
  });
186
220
  };
187
221
 
188
222
  const runOnce = async (activeRequest: ReturnType<typeof createCursorRequest>) => {
223
+ // Envelope echo quarantine (devlog 260826 gap-10): external full-replay continuations
224
+ // whose trailing input is a tool result sometimes ECHO the replayed "[Tool Result]"
225
+ // envelope as assistant text (kimi-k3 ~30-40% of multi-round probes). Hold the first
226
+ // text deltas until they provably diverge from the markers; a completed marker turns
227
+ // the turn into a retryable semantic failure BEFORE any client-visible delta escapes.
228
+ // Armed for ANY external turn whose replayed history contains a tool result — echo
229
+ // priming was observed live on user-action rounds too (the envelope lives in the
230
+ // flattened history regardless of which role ends the input).
231
+ const armEchoSniffer =
232
+ isCursorExternalWireModel(activeRequest.modelId)
233
+ && (_parsed.context.messages ?? []).some(message => message.role === "toolResult");
234
+ const echoSniffer = armEchoSniffer ? new CursorEnvelopeEchoSniffer() : undefined;
235
+ const armRoutingCommentarySniffer =
236
+ isCursorExternalWireModel(activeRequest.modelId)
237
+ && (
238
+ cursorRequestUsesCodeMode(activeRequest.tools, activeRequest.toolChoice)
239
+ || cursorRequestHasShellAlias(activeRequest.tools)
240
+ );
241
+ const routingCommentarySniffer = armRoutingCommentarySniffer
242
+ ? new CursorRoutingCommentarySniffer()
243
+ : undefined;
244
+ let guardHeld: AdapterEvent[] = [];
245
+ const releaseGuardHeld = () => {
246
+ for (const held of guardHeld) {
247
+ if (held.type !== "heartbeat") emittedOutput = true;
248
+ emit(held);
249
+ }
250
+ guardHeld = [];
251
+ };
252
+ const guardsSettled = () =>
253
+ (!echoSniffer || echoSniffer.settled)
254
+ && (!routingCommentarySniffer || routingCommentarySniffer.settled);
189
255
  await runCursorTurnWithRetry(
190
256
  makeTransport,
191
257
  {
@@ -207,14 +273,54 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
207
273
  if (message.type === "done") completedNormally = true;
208
274
  if (message.type === "tool_call_end") emittedClientTool = true;
209
275
  const captured = capturedCursorCheckpointBytes(activeTransport);
210
- if (captured) lastTransport = { captured };
211
- const events = mapCursorServerMessage(message, {
212
- kv,
213
- writeClient: clientMessage => {
214
- void activeTransport.writeClient(clientMessage);
215
- },
216
- });
217
- for (const event of events) {
276
+ if (captured) {
277
+ if (captured !== lastTransport?.captured) capturedAfterClientTool = emittedClientTool;
278
+ lastTransport = { captured };
279
+ }
280
+ const events = mapCursorServerMessage(message, {
281
+ kv,
282
+ writeClient: clientMessage => {
283
+ void activeTransport.writeClient(clientMessage);
284
+ },
285
+ });
286
+ for (const event of events) {
287
+ if (!guardsSettled()) {
288
+ if (event.type === "text_delta") {
289
+ guardHeld.push(event);
290
+ if (echoSniffer && !echoSniffer.settled) {
291
+ const decision = echoSniffer.feed(event.text);
292
+ if (decision.kind === "echo") {
293
+ guardHeld = [];
294
+ throw new CursorToolResultEchoError(decision.marker);
295
+ }
296
+ }
297
+ if (routingCommentarySniffer && !routingCommentarySniffer.settled) {
298
+ const decision = routingCommentarySniffer.feed(event.text);
299
+ if (decision.kind === "hallucination") {
300
+ guardHeld = [];
301
+ throw new CursorRoutingCommentaryError();
302
+ }
303
+ }
304
+ if (guardsSettled()) releaseGuardHeld();
305
+ continue;
306
+ } else if (event.type === "thinking_delta" || event.type === "heartbeat") {
307
+ // Reasoning before first text stays ordered; liveness still passes through.
308
+ if (event.type === "thinking_delta") {
309
+ guardHeld.push(event);
310
+ continue;
311
+ }
312
+ } else {
313
+ // A tool call, done, or error closes the text quarantine. Re-check the full
314
+ // held first line before release so a fragmented routing claim cannot leak.
315
+ echoSniffer?.finish();
316
+ const routingDecision = routingCommentarySniffer?.finish();
317
+ if (routingDecision?.kind === "hallucination") {
318
+ guardHeld = [];
319
+ throw new CursorRoutingCommentaryError();
320
+ }
321
+ releaseGuardHeld();
322
+ }
323
+ }
218
324
  if (event.type !== "heartbeat") emittedOutput = true;
219
325
  if (event.type === "done") {
220
326
  commitCapturedCheckpoint(activeRequest);
@@ -246,36 +352,82 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
246
352
  try {
247
353
  await runOnce(request);
248
354
  } catch (err) {
249
- // One-shot fallback for external-model Connect invalid_argument before any
250
- // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
251
- // resumes, local exec/MCP side effects, and already-emitted output fail closed.
355
+ const outputGuardRetryText =
356
+ err instanceof CursorToolResultEchoError
357
+ ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT
358
+ : err instanceof CursorRoutingCommentaryError
359
+ ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT
360
+ : undefined;
361
+ // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11).
362
+ // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation
363
+ // retry is safe. A second rejection propagates as an error rather than looping.
252
364
  if (
253
- !isCursorInvalidArgumentError(err)
254
- || !isCursorExternalWireModel(request.modelId)
255
- || lastRawIsToolResult
256
- || emittedOutput
257
- || replayUnsafe
258
- || incoming.abortSignal?.aborted
365
+ outputGuardRetryText
366
+ && !emittedOutput
367
+ && !replayUnsafe
368
+ && !incoming.abortSignal?.aborted
259
369
  ) {
260
- throw err;
261
- }
262
- const failedConversationId = request.conversationId;
263
- lastTransport = undefined;
264
- _parsed._cursorConversationId = undefined;
265
- request = createCursorRequest(_parsed, { forceFreshConversation: true });
266
- rekeyContextUsage(failedConversationId, request.conversationId);
267
- _parsed._cursorConversationId = request.conversationId;
268
- // Persist recovery for store:false clients that only send a parent thread id, so the
269
- // next turn does not recompute the stale deterministic thread hash. Isolated helper /
270
- // compaction turns must not park their throwaway id under the parent thread key.
271
- if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
272
- rememberCursorThreadConversation(
273
- _parsed._clientThreadId,
274
- request.conversationId,
275
- _parsed._cursorIdentityScope,
370
+ debugProviderDiagnostic(
371
+ "cursor",
372
+ err instanceof CursorToolResultEchoError
373
+ ? "envelope-echo-retry"
374
+ : "routing-commentary-retry",
375
+ {
376
+ wireModel: request.modelId,
377
+ conversationHash: request.conversationId.slice(0, 16),
378
+ },
276
379
  );
380
+ const echoedConversationId = request.conversationId;
381
+ lastTransport = undefined;
382
+ _parsed._cursorConversationId = undefined;
383
+ request = {
384
+ ...createCursorRequest(_parsed, { forceFreshConversation: true }),
385
+ echoRetryContinuationText: outputGuardRetryText,
386
+ };
387
+ rekeyContextUsage(echoedConversationId, request.conversationId);
388
+ _parsed._cursorConversationId = request.conversationId;
389
+ const echoThreadOwner = cursorClientThreadOwner(_parsed);
390
+ if (echoThreadOwner && _parsed._cursorIsolateConversation !== true) {
391
+ rememberCursorThreadConversation(
392
+ echoThreadOwner,
393
+ request.conversationId,
394
+ _parsed._cursorIdentityScope,
395
+ );
396
+ }
397
+ await runOnce(request);
398
+ } else {
399
+ // One-shot fallback for external-model Connect invalid_argument before any
400
+ // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
401
+ // resumes, local exec/MCP side effects, and already-emitted output fail closed.
402
+ if (
403
+ !isCursorInvalidArgumentError(err)
404
+ || !isCursorExternalWireModel(request.modelId)
405
+ || lastRawIsToolResult
406
+ || emittedOutput
407
+ || replayUnsafe
408
+ || incoming.abortSignal?.aborted
409
+ ) {
410
+ throw err;
411
+ }
412
+ const failedConversationId = request.conversationId;
413
+ lastTransport = undefined;
414
+ _parsed._cursorConversationId = undefined;
415
+ request = createCursorRequest(_parsed, { forceFreshConversation: true });
416
+ rekeyContextUsage(failedConversationId, request.conversationId);
417
+ _parsed._cursorConversationId = request.conversationId;
418
+ // Persist recovery for store:false clients that send any stable Cursor thread owner, so
419
+ // the next turn does not recompute the stale deterministic thread hash. Isolated helper /
420
+ // compaction turns must not park their throwaway id under the parent or Desktop owner.
421
+ const threadOwner = cursorClientThreadOwner(_parsed);
422
+ if (threadOwner && _parsed._cursorIsolateConversation !== true) {
423
+ rememberCursorThreadConversation(
424
+ threadOwner,
425
+ request.conversationId,
426
+ _parsed._cursorIdentityScope,
427
+ );
428
+ }
429
+ await runOnce(request);
277
430
  }
278
- await runOnce(request);
279
431
  }
280
432
  if (
281
433
  request.checkpointInvalidationReason
@@ -19,6 +19,7 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
19
19
 
20
20
  interface ReplayCall {
21
21
  signature: string;
22
+ signatures?: string[];
22
23
  sizeBytes: number;
23
24
  touchedAtMs: number;
24
25
  }
@@ -34,6 +35,7 @@ interface ReplayEntry {
34
35
  }
35
36
 
36
37
  const MIN_SIGNATURE_LEN = 16;
38
+ const MAX_SIGNATURES_PER_CALL = 32;
37
39
  const REPLAY_TTL_MS = 60 * 60 * 1000; // 1h
38
40
  export const ANTIGRAVITY_REPLAY_MAX_ENTRIES = 10_240;
39
41
  const REPLAY_EVICT_BATCH = 128;
@@ -103,16 +105,38 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void {
103
105
  for (const pair of rec.byCall) {
104
106
  if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") continue;
105
107
  if (!/^[0-9a-f]{64}$/.test(pair[0])) continue;
106
- const call = pair[1] as { signature?: unknown; touchedAtMs?: unknown } | null;
108
+ const call = pair[1] as { signature?: unknown; signatures?: unknown; touchedAtMs?: unknown } | null;
107
109
  if (!call || typeof call !== "object" || Array.isArray(call)) continue;
108
110
  if (typeof call.signature !== "string" || call.signature.length < MIN_SIGNATURE_LEN) continue;
109
111
  if (typeof call.touchedAtMs !== "number" || !Number.isFinite(call.touchedAtMs)) continue;
110
- // Never trust a serialized sizeBytes: a forged snapshot could claim a tiny
111
- // size for a huge signature and bypass every byte cap. Recompute from the
112
- // signature itself, exactly like the live write path.
113
- const signatureBytes = utf8.encode(call.signature).byteLength;
114
- if (signatureBytes > replayLimits.maxSignatureBytes) continue;
115
- const callBytes = utf8.encode(pair[0]).byteLength + signatureBytes;
112
+ let sigs: string[] = [];
113
+ if (Array.isArray(call.signatures)) {
114
+ for (const s of call.signatures) {
115
+ if (typeof s === "string" && s.length >= MIN_SIGNATURE_LEN) {
116
+ sigs.push(s);
117
+ }
118
+ }
119
+ }
120
+ if (sigs.length === 0) {
121
+ sigs = [call.signature];
122
+ } else if (!sigs.includes(call.signature)) {
123
+ sigs.push(call.signature);
124
+ }
125
+ if (sigs.length > MAX_SIGNATURES_PER_CALL) {
126
+ sigs = sigs.slice(-MAX_SIGNATURES_PER_CALL);
127
+ }
128
+ let totalSigBytes = 0;
129
+ let anySigOversized = false;
130
+ for (const s of sigs) {
131
+ const sb = utf8.encode(s).byteLength;
132
+ if (sb > replayLimits.maxSignatureBytes) {
133
+ anySigOversized = true;
134
+ break;
135
+ }
136
+ totalSigBytes += sb;
137
+ }
138
+ if (anySigOversized) continue;
139
+ const callBytes = utf8.encode(pair[0]).byteLength + totalSigBytes;
116
140
  if (callBytes > replayLimits.maxBytesPerSession) continue;
117
141
  // A duplicated call key would overstate entry.bytes (the map keeps only the
118
142
  // last value) and could evict valid sessions; keep the first occurrence.
@@ -122,6 +146,7 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void {
122
146
  }
123
147
  byCall.set(pair[0], {
124
148
  signature: call.signature,
149
+ signatures: sigs,
125
150
  sizeBytes: callBytes,
126
151
  touchedAtMs: call.touchedAtMs,
127
152
  });
@@ -226,7 +251,11 @@ async function persistReplaySnapshotNow(): Promise<void> {
226
251
  for (const [key, entry] of [...replayCache].sort((a, b) => b[1].lastActiveAtMs - a[1].lastActiveAtMs)) {
227
252
  const byCall = [...entry.byCall].map(([callKey, call]) => [
228
253
  callKey,
229
- { signature: call.signature, touchedAtMs: call.touchedAtMs },
254
+ {
255
+ signature: call.signature,
256
+ ...(call.signatures && call.signatures.length > 1 ? { signatures: call.signatures } : {}),
257
+ touchedAtMs: call.touchedAtMs,
258
+ },
230
259
  ]);
231
260
  const persistEntry: [string, unknown] = [key, {
232
261
  byCall,
@@ -644,10 +673,37 @@ export function observeAntigravityReplay(
644
673
  const ck = functionCallKey(fc.name, fc.args);
645
674
  if (!ck) continue; // only function-call signatures are replayable by identity
646
675
  const signatureBytes = utf8.encode(callSig).byteLength;
647
- const sizeBytes = utf8.encode(ck).byteLength + signatureBytes;
648
- if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue;
676
+ if (signatureBytes > replayLimits.maxSignatureBytes) continue;
677
+
678
+ const existingCall = entry.byCall.get(ck);
679
+ let sigs: string[];
680
+ if (existingCall) {
681
+ sigs = existingCall.signatures && existingCall.signatures.length > 0
682
+ ? [...existingCall.signatures]
683
+ : [existingCall.signature];
684
+ if (!sigs.includes(callSig)) {
685
+ if (sigs.length >= MAX_SIGNATURES_PER_CALL) {
686
+ sigs.shift();
687
+ }
688
+ sigs.push(callSig);
689
+ }
690
+ } else {
691
+ sigs = [callSig];
692
+ }
693
+ let totalSigBytes = 0;
694
+ for (const s of sigs) {
695
+ totalSigBytes += utf8.encode(s).byteLength;
696
+ }
697
+ const sizeBytes = utf8.encode(ck).byteLength + totalSigBytes;
698
+ if (sizeBytes > replayLimits.maxBytesPerSession) continue;
699
+
649
700
  deleteReplayCall(entry, ck);
650
- entry.byCall.set(ck, { signature: callSig, sizeBytes, touchedAtMs: now });
701
+ entry.byCall.set(ck, {
702
+ signature: callSig,
703
+ signatures: sigs,
704
+ sizeBytes,
705
+ touchedAtMs: now,
706
+ });
651
707
  entry.bytes += sizeBytes;
652
708
  replayBytes += sizeBytes;
653
709
  inserted = true;
@@ -692,15 +748,22 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
692
748
  if (!entry) {
693
749
  return contents;
694
750
  }
751
+
695
752
  let touched = false;
696
- for (const c of contents as { role?: string; parts?: unknown[] }[]) {
753
+ // Align from the END of the recorded signature list. History may have been truncated
754
+ // (compaction / previous_response_id): the last remaining occurrence is the most recent call,
755
+ // so it must receive the newest signature. Iterate backwards and count every occurrence
756
+ // (signed or not) so signed Mechanism-① parts still occupy their chronological slot.
757
+ const reverseOccurrence = new Map<string, number>();
758
+ for (let ci = (contents as { role?: string; parts?: unknown[] }[]).length - 1; ci >= 0; ci--) {
759
+ const c = (contents as { role?: string; parts?: unknown[] }[])[ci];
697
760
  if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue;
698
- for (const raw of c.parts) {
761
+ for (let pi = c.parts.length - 1; pi >= 0; pi--) {
762
+ const raw = c.parts[pi];
699
763
  if (!raw || typeof raw !== "object") continue;
700
764
  const part = raw as Record<string, unknown>;
701
765
  const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
702
766
  if (!fc) continue;
703
- if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue;
704
767
  const ck = functionCallKey(fc.name, fc.args);
705
768
  let call = ck ? entry.byCall.get(ck) : undefined;
706
769
  let matchedKey = ck;
@@ -717,27 +780,43 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
717
780
  ) {
718
781
  try {
719
782
  const parsedInput = JSON.parse(trimmedInput);
720
- if (parsedInput && typeof parsedInput === "object") {
721
- const altKey = functionCallKey(fc.name, parsedInput);
722
- if (altKey && entry.byCall.has(altKey)) {
723
- call = entry.byCall.get(altKey);
724
- matchedKey = altKey;
783
+ if (parsedInput && typeof parsedInput === "object") {
784
+ const altKey = functionCallKey(fc.name, parsedInput);
785
+ if (altKey && entry.byCall.has(altKey)) {
786
+ call = entry.byCall.get(altKey);
787
+ matchedKey = altKey;
788
+ }
725
789
  }
726
- }
727
790
  } catch {
728
791
  // not JSON, keep default
729
792
  }
730
793
  }
731
794
  }
732
795
  }
733
- if (call && matchedKey) {
796
+ if (matchedKey) {
797
+ const revIdx = reverseOccurrence.get(matchedKey) ?? 0;
798
+ reverseOccurrence.set(matchedKey, revIdx + 1);
799
+ if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) {
800
+ // Already signed (e.g. Mechanism ① or upstream response), preserve it.
801
+ continue;
802
+ }
803
+ if (call) {
804
+ const sigs = call.signatures && call.signatures.length > 0 ? call.signatures : [call.signature];
805
+ const chosenSig = revIdx < sigs.length
806
+ ? sigs[sigs.length - 1 - revIdx]
807
+ : sigs[sigs.length - 1] ?? call.signature;
808
+ part.thoughtSignature = chosenSig;
809
+ entry.byCall.delete(matchedKey);
810
+ entry.byCall.set(matchedKey, { ...call, touchedAtMs: now });
811
+ touched = true;
812
+ }
813
+ } else if (part.thoughtSignature === undefined && part.thought_signature === undefined && call) {
734
814
  part.thoughtSignature = call.signature;
735
- entry.byCall.delete(matchedKey);
736
- entry.byCall.set(matchedKey, { ...call, touchedAtMs: now });
737
815
  touched = true;
738
816
  }
739
817
  }
740
818
  }
819
+
741
820
  if (touched) {
742
821
  entry.lastActiveAtMs = now;
743
822
  refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
@@ -15,14 +15,50 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st
15
15
  };
16
16
  }
17
17
 
18
+
19
+
20
+ const GOOGLE_QUOTA_EXHAUSTED_NEEDLES = [
21
+ "quotafailure",
22
+ "quota exceeded",
23
+ "exceeded your current quota",
24
+ "billing",
25
+ "individual quota reached",
26
+ "quota reached",
27
+ "enable overages",
28
+ "exhausted your capacity",
29
+ "daily limit reached",
30
+ "weekly limit reached",
31
+ ];
32
+
33
+ // Per-minute / per-second / concurrency limits are transient rate limits: they should be
34
+ // retried, not treated as hard quota exhaustion. These guards run before the needles so a
35
+ // message like "Per-minute quota exceeded" stays in the retryable bucket.
36
+ const GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS = [
37
+ "per minute",
38
+ "per-minute",
39
+ "per min",
40
+ "rpm",
41
+ "requests per minute",
42
+ "too many requests",
43
+ "rate limit",
44
+ "retry after",
45
+ // Upstream writes the header name both ways in prose ("retry-after: 60"); matching only the
46
+ // spaced spelling let a transient 429 fall through to the exhaustion needles below.
47
+ "retry-after",
48
+ "concurrent request limit",
49
+ ];
50
+
51
+ export function isGoogleQuotaExhaustedText(text: string): boolean {
52
+ const lower = text.toLowerCase();
53
+ if (GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS.some(needle => lower.includes(needle))) return false;
54
+ return GOOGLE_QUOTA_EXHAUSTED_NEEDLES.some(needle => lower.includes(needle));
55
+ }
56
+
57
+
18
58
  function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
19
59
  const lower = `${enumStatus ?? ""} ${text}`.toLowerCase();
20
- const quotaExhausted =
21
- lower.includes("quotafailure") ||
22
- lower.includes("quota exceeded") ||
23
- lower.includes("exceeded your current quota") ||
24
- lower.includes("billing");
25
- if (enumStatus === "RESOURCE_EXHAUSTED" && quotaExhausted) return `${label} quota exhausted`;
60
+ const quotaExhausted = isGoogleQuotaExhaustedText(lower);
61
+ if ((!enumStatus || enumStatus === "RESOURCE_EXHAUSTED") && quotaExhausted) return `${label} quota exhausted`;
26
62
  if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) {
27
63
  return `${label} rate limit exceeded`;
28
64
  }
@@ -76,10 +112,6 @@ export function retryableGoogleStatus(status: number): boolean {
76
112
  */
77
113
  export function isQuotaExhaustedBody(payloadText: string): boolean {
78
114
  const { message, status } = googleErrorDetail(payloadText);
79
- if (status !== "RESOURCE_EXHAUSTED") return false;
80
- const lower = (message ?? "").toLowerCase();
81
- return lower.includes("quotafailure")
82
- || lower.includes("quota exceeded")
83
- || lower.includes("exceeded your current quota")
84
- || lower.includes("billing");
115
+ if (status && status !== "RESOURCE_EXHAUSTED") return false;
116
+ return isGoogleQuotaExhaustedText(message ?? payloadText);
85
117
  }