@oh-my-pi/pi-ai 17.3.4 → 17.3.7

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 (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/auth-storage.d.ts +12 -2
  3. package/dist/types/error/rate-limit.d.ts +5 -2
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/oneshot-retry.d.ts +89 -0
  6. package/dist/types/providers/anthropic-wire.d.ts +18 -5
  7. package/dist/types/providers/openai-shared.d.ts +1 -1
  8. package/dist/types/types.d.ts +5 -4
  9. package/dist/types/usage/kimi.d.ts +3 -1
  10. package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
  11. package/dist/types/utils/schema/wire.d.ts +6 -0
  12. package/dist/types/utils/thinking-loop.d.ts +12 -9
  13. package/package.json +5 -5
  14. package/src/auth-storage.ts +36 -5
  15. package/src/error/flags.ts +6 -2
  16. package/src/error/rate-limit.ts +31 -2
  17. package/src/index.ts +1 -0
  18. package/src/oneshot-retry.ts +230 -0
  19. package/src/providers/anthropic-messages-server.ts +5 -5
  20. package/src/providers/anthropic-wire.ts +31 -5
  21. package/src/providers/anthropic.ts +15 -4
  22. package/src/providers/cursor.ts +44 -4
  23. package/src/providers/openai-completions.ts +107 -63
  24. package/src/providers/openai-responses.ts +13 -10
  25. package/src/providers/openai-shared.ts +10 -3
  26. package/src/registry/alibaba-coding-plan.ts +2 -2
  27. package/src/registry/oauth/kimi.ts +20 -0
  28. package/src/stream.ts +59 -57
  29. package/src/types.ts +5 -4
  30. package/src/usage/kimi.ts +15 -0
  31. package/src/usage/umans.ts +93 -20
  32. package/src/utils/leaked-thinking-stream.ts +2 -2
  33. package/src/utils/schema/strict-tool-validation.ts +39 -5
  34. package/src/utils/schema/wire.ts +31 -0
  35. package/src/utils/thinking-loop.ts +96 -63
@@ -0,0 +1,230 @@
1
+ import { extractRetryHint } from "@oh-my-pi/pi-utils";
2
+ import * as AIError from "./error";
3
+ import type { AssistantMessage } from "./types";
4
+ import { getHeadersFromError, getRetryAfterMsFromHeaders, type HeadersLike } from "./utils/retry-after";
5
+
6
+ /**
7
+ * Transient-failure retry for **oneshot** (non-agent-loop) completions.
8
+ *
9
+ * Why this exists: `streamSimple`/`completeSimple` retry *auth* failures
10
+ * (credential rotation) but deliberately surface *transient* provider failures
11
+ * — Anthropic `overloaded_error`, `rate_limit_error`, HTTP 429/500/502/503/529
12
+ * — as a **resolved** `AssistantMessage` with `stopReason: "error"`. For the
13
+ * main agent turn that is correct: `TurnRecovery` owns recovery there, and it
14
+ * must refuse to replay once tool calls or visible text have streamed.
15
+ *
16
+ * Oneshots have no such hazard. A summary, title, handoff, or image
17
+ * description produces no side effects, so re-issuing the whole request is
18
+ * safe and is almost always what the caller wants. Before this helper every
19
+ * oneshot call site had to re-implement that decision, and most did not —
20
+ * failing on the first blip, or swallowing it into `null` so a transient
21
+ * overload was indistinguishable from a legitimate empty result.
22
+ *
23
+ * Classification reuses the existing provider predicates (`AIError`), so the
24
+ * set of retryable Anthropic failures stays defined in exactly one place.
25
+ * Usage limits are included: unlike the provider loop — which excludes them so
26
+ * credential rotation can own them — a oneshot has no rotation layer above it,
27
+ * and the retry hint the provider supplies (`retry-after`, "try again in ~5m")
28
+ * is honored, so waiting is the correct response.
29
+ */
30
+ export interface OneshotRetryOptions {
31
+ /** Total attempts, including the first. Default 3. Values < 1 are treated as 1. */
32
+ maxAttempts?: number;
33
+ /** First backoff step in ms; doubles per attempt. Default 500. */
34
+ baseDelayMs?: number;
35
+ /**
36
+ * Upper bound for a single wait. Default 30_000. A provider retry hint
37
+ * longer than this aborts the retry instead of parking the caller — the
38
+ * error surfaces so higher-level recovery (or the user) can decide.
39
+ */
40
+ maxDelayMs?: number;
41
+ /**
42
+ * Stops further attempts. Two distinct paths, both preserving the caller's
43
+ * intent: an abort already visible when an attempt settles surfaces that
44
+ * attempt's own result (`completeSimple` reports `stopReason: "aborted"`),
45
+ * while an abort that lands during the backoff wait rejects with the abort
46
+ * reason — a user cancel stays a cancel and is never relabelled as the
47
+ * provider failure we happened to be waiting on.
48
+ *
49
+ * This helper does NOT pass the signal into `run` — cancelling the in-flight
50
+ * request is the closure's job, because a per-attempt deadline must be
51
+ * rebuilt on every attempt. Construct it inside `run`
52
+ * (`signal: AbortSignal.timeout(MS)`, or `AbortSignal.any([outer, perAttempt])`);
53
+ * a deadline captured outside would fire once and then abort every retry,
54
+ * silently turning this helper into a single attempt.
55
+ */
56
+ signal?: AbortSignal;
57
+ /**
58
+ * Headers of the attempt that just failed, used to honor `retry-after`.
59
+ *
60
+ * Load-bearing: a transient Anthropic failure arrives as a **resolved**
61
+ * `AssistantMessage`, and `AssistantMessage` carries no headers — so without
62
+ * this the real `retry-after` / `x-ratelimit-reset` values on a 429/529 are
63
+ * invisible and only the (usually hint-free) error text is available.
64
+ * Callers that already capture headers via `SimpleStreamOptions.onResponse`
65
+ * should return the latest capture here; it is read once per failed attempt.
66
+ * Thrown errors need no wiring — headers are recovered from the error itself.
67
+ */
68
+ getResponseHeaders?: () => HeadersLike;
69
+ /** Observability hook. Fires immediately before sleeping. */
70
+ onRetry?: (info: OneshotRetryInfo) => void;
71
+ }
72
+
73
+ export interface OneshotRetryInfo {
74
+ /** 1-based index of the attempt that just failed. */
75
+ attempt: number;
76
+ maxAttempts: number;
77
+ delayMs: number;
78
+ /** True when `delayMs` came from a provider retry hint rather than backoff. */
79
+ fromRetryHint: boolean;
80
+ errorMessage: string;
81
+ /** `AIError` classification bits of the failure. */
82
+ errorId: number;
83
+ }
84
+
85
+ const DEFAULT_MAX_ATTEMPTS = 3;
86
+ const DEFAULT_BASE_DELAY_MS = 500;
87
+ const DEFAULT_MAX_DELAY_MS = 30_000;
88
+ /** Cap on pure backoff growth. A provider hint may still exceed this, up to `maxDelayMs`. */
89
+ const BACKOFF_CEILING_MS = 8_000;
90
+ const RETRY_AFTER_MS_SUFFIX = /(?:^|\s)retry-after-ms=([0-9]+(?:\.[0-9]+)?)(?=\s|$)/i;
91
+
92
+ function backoffDelayMs(attempt: number, baseDelayMs: number): number {
93
+ const growth = Math.min(baseDelayMs * 2 ** (attempt - 1), BACKOFF_CEILING_MS);
94
+ // 75-100% jitter, matching the provider loop and TurnRecovery, so a fleet of
95
+ // concurrent oneshots does not re-converge on the same instant.
96
+ return Math.round(growth * (0.75 + Math.random() * 0.25));
97
+ }
98
+
99
+ /** Retryable when the provider says transient, or when it says "wait, then retry". */
100
+ function isRetryableOneshotFailure(errorId: number, errorStatus: number | undefined, errorMessage: string): boolean {
101
+ // llama.cpp reports deterministic tool-call JSON parse failures as HTTP 500.
102
+ // Replaying the same prompt produces the same malformed output.
103
+ if (AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(errorMessage)) return false;
104
+ if (AIError.is(errorId, AIError.Flag.ContentBlocked)) return false;
105
+ return (
106
+ AIError.isTransientStatus(errorStatus) ||
107
+ AIError.is(errorId, AIError.Flag.Transient) ||
108
+ AIError.is(errorId, AIError.Flag.UsageLimit) ||
109
+ AIError.retriable(errorId)
110
+ );
111
+ }
112
+
113
+ function sleep(delayMs: number, signal?: AbortSignal): Promise<void> {
114
+ if (delayMs <= 0) return Promise.resolve();
115
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
116
+ const timer = setTimeout(() => {
117
+ signal?.removeEventListener("abort", onAbort);
118
+ resolve();
119
+ }, delayMs);
120
+ const onAbort = () => {
121
+ clearTimeout(timer);
122
+ reject(signal?.reason ?? new AIError.AbortError("oneshot retry aborted"));
123
+ };
124
+ if (signal) {
125
+ if (signal.aborted) {
126
+ clearTimeout(timer);
127
+ return Promise.reject(signal.reason ?? new AIError.AbortError("oneshot retry aborted"));
128
+ }
129
+ signal.addEventListener("abort", onAbort, { once: true });
130
+ }
131
+ return promise;
132
+ }
133
+
134
+ /**
135
+ * Run a oneshot completion, retrying transient provider failures.
136
+ *
137
+ * Handles both failure shapes: a resolved `AssistantMessage` carrying
138
+ * `stopReason: "error"` (what `completeSimple` produces) and a thrown error
139
+ * (what the raw HTTP helpers produce). A non-retryable failure is returned or
140
+ * rethrown unchanged, so existing caller error handling keeps working — this
141
+ * only removes the *first-blip* failure mode.
142
+ */
143
+ export async function retryTransientCompletion(
144
+ run: (attempt: number) => Promise<AssistantMessage>,
145
+ options?: OneshotRetryOptions,
146
+ ): Promise<AssistantMessage> {
147
+ const maxAttempts = Math.max(1, options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
148
+ const baseDelayMs = options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
149
+ const maxDelayMs = options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
150
+ const signal = options?.signal;
151
+
152
+ for (let attempt = 1; ; attempt++) {
153
+ let message: AssistantMessage | undefined;
154
+ let thrown: unknown;
155
+ try {
156
+ message = await run(attempt);
157
+ if (message.stopReason !== "error") return message;
158
+ } catch (error) {
159
+ thrown = error;
160
+ }
161
+ // A caller abort is never a transient failure — surface it immediately so
162
+ // cancellation stays responsive.
163
+ if (signal?.aborted) {
164
+ if (thrown !== undefined) throw thrown;
165
+ return message as AssistantMessage;
166
+ }
167
+
168
+ const errorId =
169
+ thrown !== undefined ? AIError.classify(thrown) : AIError.classifyMessage(message as AssistantMessage);
170
+ if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) {
171
+ if (thrown !== undefined) throw thrown;
172
+ return message as AssistantMessage;
173
+ }
174
+ const errorMessage =
175
+ thrown !== undefined
176
+ ? thrown instanceof Error
177
+ ? thrown.message
178
+ : String(thrown)
179
+ : ((message as AssistantMessage).errorMessage ?? "unknown error");
180
+ const errorStatus = thrown !== undefined ? AIError.status(thrown) : (message as AssistantMessage).errorStatus;
181
+ const lastAttempt = attempt >= maxAttempts;
182
+ if (lastAttempt || !isRetryableOneshotFailure(errorId, errorStatus, errorMessage)) {
183
+ if (thrown !== undefined) throw thrown;
184
+ return message as AssistantMessage;
185
+ }
186
+
187
+ // Headers first: a real Anthropic 429/529 carries `retry-after` /
188
+ // `x-ratelimit-reset*` in the response, and the resolved AssistantMessage
189
+ // has none — the caller supplies them via `getResponseHeaders`. Thrown
190
+ // errors (e.g. AnthropicApiError) carry their own headers.
191
+ const headers: HeadersLike = thrown !== undefined ? getHeadersFromError(thrown) : options?.getResponseHeaders?.();
192
+ const headerHintMs = getRetryAfterMsFromHeaders(headers);
193
+ const extractedTextHintMs = extractRetryHint(undefined, errorMessage);
194
+ const suffixValue = RETRY_AFTER_MS_SUFFIX.exec(errorMessage)?.[1];
195
+ const parsedSuffixMs = suffixValue === undefined ? undefined : Number(suffixValue);
196
+ const suffixHintMs =
197
+ parsedSuffixMs !== undefined && Number.isFinite(parsedSuffixMs) && parsedSuffixMs > 0
198
+ ? Math.ceil(parsedSuffixMs)
199
+ : undefined;
200
+ const textHintMs =
201
+ extractedTextHintMs === undefined && suffixHintMs === undefined
202
+ ? undefined
203
+ : Math.max(extractedTextHintMs ?? 0, suffixHintMs ?? 0);
204
+ const hintMs =
205
+ headerHintMs === undefined && textHintMs === undefined
206
+ ? undefined
207
+ : Math.max(headerHintMs ?? 0, textHintMs ?? 0);
208
+ // An over-cap hint means "come back much later"; parking a oneshot that
209
+ // long is worse than surfacing the failure to the caller.
210
+ if (hintMs !== undefined && hintMs > maxDelayMs) {
211
+ if (thrown !== undefined) throw thrown;
212
+ return message as AssistantMessage;
213
+ }
214
+ const backoff = backoffDelayMs(attempt, baseDelayMs);
215
+ const delayMs = Math.min(Math.max(hintMs ?? 0, backoff), maxDelayMs);
216
+
217
+ options?.onRetry?.({
218
+ attempt,
219
+ maxAttempts,
220
+ delayMs,
221
+ fromRetryHint: hintMs !== undefined && hintMs >= backoff,
222
+ errorMessage,
223
+ errorId,
224
+ });
225
+ // Aborting mid-backoff rejects with the caller's abort reason: a user
226
+ // cancel must stay a cancel, not get relabelled as the provider failure we
227
+ // happened to be waiting on.
228
+ await sleep(delayMs, signal);
229
+ }
230
+ }
@@ -27,7 +27,7 @@ import {
27
27
  type AnthropicUserContentBlock,
28
28
  anthropicMessagesRequestSchema,
29
29
  } from "./anthropic-messages-server-schema";
30
- import { isAnthropicWebSearchHistoryBlock } from "./anthropic-wire";
30
+ import { isAnthropicServerToolHistoryBlock } from "./anthropic-wire";
31
31
 
32
32
  /**
33
33
  * Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai
@@ -216,10 +216,10 @@ function walkAssistantContent(
216
216
  break;
217
217
  case "server_tool_use":
218
218
  case "web_search_tool_result":
219
- if (isAnthropicWebSearchHistoryBlock(block)) {
220
- // Native web-search call/result. Anthropic requires these
221
- // replayed verbatim (encrypted_content included), so retain
222
- // the block instead of flattening it to text.
219
+ case "tool_search_tool_result":
220
+ if (isAnthropicServerToolHistoryBlock(block)) {
221
+ // Anthropic requires supported server-tool call/results replayed
222
+ // verbatim, so retain each opaque block instead of flattening it.
223
223
  out.push({ type: "anthropicServerTool", block: { ...block } });
224
224
  } else {
225
225
  // Other server tools use distinct result block types that omp
@@ -77,6 +77,11 @@ export type ServerToolUseBlockParam = {
77
77
  /** Web-search server-tool call whose matching result is replayable by omp. */
78
78
  export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & { name: "web_search" };
79
79
 
80
+ /** Tool-search server-tool call whose matching result is replayable by omp. */
81
+ export type ToolSearchServerToolUseBlockParam = ServerToolUseBlockParam & {
82
+ name: "tool_search_tool_regex" | "tool_search_tool_bm25";
83
+ };
84
+
80
85
  /** Native web-search result replayed inside an assistant turn. */
81
86
  export type WebSearchToolResultBlockParam = {
82
87
  type: "web_search_tool_result";
@@ -85,18 +90,37 @@ export type WebSearchToolResultBlockParam = {
85
90
  [key: string]: unknown;
86
91
  };
87
92
 
88
- /** True for the complete native web-search history variants omp can replay. */
89
- export function isAnthropicWebSearchHistoryBlock(block: {
93
+ /** Native tool-search result replayed inside an assistant turn. */
94
+ export type ToolSearchToolResultBlockParam = {
95
+ type: "tool_search_tool_result";
96
+ tool_use_id: string;
97
+ content: unknown;
98
+ [key: string]: unknown;
99
+ };
100
+
101
+ /** Anthropic server-tool history variants omp can replay atomically. */
102
+ export type AnthropicServerToolHistoryBlockParam =
103
+ | WebSearchServerToolUseBlockParam
104
+ | WebSearchToolResultBlockParam
105
+ | ToolSearchServerToolUseBlockParam
106
+ | ToolSearchToolResultBlockParam;
107
+
108
+ /** True when a block is complete Anthropic server-tool history omp can replay. */
109
+ export function isAnthropicServerToolHistoryBlock(block: {
90
110
  type: string;
91
111
  name?: unknown;
92
112
  id?: unknown;
93
113
  tool_use_id?: unknown;
94
114
  content?: unknown;
95
- }): block is WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam {
115
+ }): block is AnthropicServerToolHistoryBlockParam {
96
116
  if (block.type === "server_tool_use") {
97
- return block.name === "web_search" && typeof block.id === "string" && block.id.length > 0;
117
+ const supportedName =
118
+ block.name === "web_search" ||
119
+ block.name === "tool_search_tool_regex" ||
120
+ block.name === "tool_search_tool_bm25";
121
+ return supportedName && typeof block.id === "string" && block.id.length > 0;
98
122
  }
99
- if (block.type === "web_search_tool_result") {
123
+ if (block.type === "web_search_tool_result" || block.type === "tool_search_tool_result") {
100
124
  return typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 && Object.hasOwn(block, "content");
101
125
  }
102
126
  return false;
@@ -132,6 +156,7 @@ export type ContentBlockParam =
132
156
  | ToolResultBlockParam
133
157
  | ServerToolUseBlockParam
134
158
  | WebSearchToolResultBlockParam
159
+ | ToolSearchToolResultBlockParam
135
160
  | ThinkingBlockParam
136
161
  | RedactedThinkingBlockParam
137
162
  | FallbackBlockParam;
@@ -319,6 +344,7 @@ export type ResponseContentBlock =
319
344
  | { type: "tool_use"; id: string; name: string; input?: Record<string, unknown> | null }
320
345
  | ServerToolUseBlockParam
321
346
  | WebSearchToolResultBlockParam
347
+ | ToolSearchToolResultBlockParam
322
348
  | { type: "fallback"; from: { model: string }; to: { model: string } };
323
349
 
324
350
  export type ContentBlockDelta =
@@ -79,7 +79,7 @@ import {
79
79
  type Usage as AnthropicWireUsage,
80
80
  type ContentBlockParam,
81
81
  type FallbackParam,
82
- isAnthropicWebSearchHistoryBlock,
82
+ isAnthropicServerToolHistoryBlock,
83
83
  type MessageCreateParams,
84
84
  type MessageCreateParamsStreaming,
85
85
  type MessageParam,
@@ -2404,7 +2404,7 @@ const streamAnthropicOnce = (
2404
2404
  streamedReplayUnsafeContent = true;
2405
2405
  const block: Block = {
2406
2406
  type: "thinking",
2407
- thinking: "",
2407
+ thinking: event.content_block.thinking ?? "",
2408
2408
  thinkingSignature: "",
2409
2409
  [kStreamingBlockIndex]: event.index,
2410
2410
  };
@@ -2416,6 +2416,14 @@ const streamAnthropicOnce = (
2416
2416
  contentIndex,
2417
2417
  partial: output,
2418
2418
  });
2419
+ if (block.thinking) {
2420
+ stream.push({
2421
+ type: "thinking_delta",
2422
+ contentIndex,
2423
+ delta: block.thinking,
2424
+ partial: output,
2425
+ });
2426
+ }
2419
2427
  } else if (event.content_block.type === "redacted_thinking") {
2420
2428
  streamedReplayUnsafeContent = true;
2421
2429
  const block: Block = {
@@ -2429,8 +2437,11 @@ const streamAnthropicOnce = (
2429
2437
  kind: "redactedThinking",
2430
2438
  });
2431
2439
  } else if (
2432
- isAnthropicWebSearchHistoryBlock(event.content_block) &&
2433
- umansGatewayWebSearchHeader === undefined
2440
+ isAnthropicServerToolHistoryBlock(event.content_block) &&
2441
+ (umansGatewayWebSearchHeader === undefined ||
2442
+ (event.content_block.type === "server_tool_use"
2443
+ ? event.content_block.name !== "web_search"
2444
+ : event.content_block.type !== "web_search_tool_result"))
2434
2445
  ) {
2435
2446
  streamedReplayUnsafeContent = true;
2436
2447
  const block: Block = {
@@ -293,11 +293,23 @@ const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
293
293
  * model reads it and should route around the capability, not retry the call.
294
294
  */
295
295
  const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client";
296
+ /** Bare gRPC `resource_exhausted` end-streams (also inside a Connect error message). */
297
+ const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/i;
296
298
  const NOT_IMPLEMENTED = `Not implemented by this client`;
297
299
 
298
300
  const conversationStateCache = new Map<string, ConversationStateStructure>();
299
301
  const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
300
302
  const warnedCursorKimiK3ReplayMessages = new Set<string>();
303
+ /**
304
+ * Base conversation id → rotated wire id (#8345). Cursor's backend can pin a
305
+ * per-conversation rejection (bare `resource_exhausted`, zero tokens) to one
306
+ * conversationId forever; the session is then unusable until /fork mints a
307
+ * new id. On the first such failure the id is rotated once and the cached
308
+ * state migrates, so the retry loop's next attempt starts a fresh
309
+ * conversation — the same recovery /fork performs. Keyed by the base id the
310
+ * caller derived, so a failed rotation is never repeated.
311
+ */
312
+ const rotatedConversationIds = new Map<string, string>();
301
313
 
302
314
  export interface CursorOptions extends StreamOptions {
303
315
  customSystemPrompt?: string;
@@ -587,13 +599,19 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
587
599
  h2Completion.resolve();
588
600
  };
589
601
 
602
+ // Hoisted out of the try block: the #8345 rotation in the catch path
603
+ // needs both ids, and the catch block cannot see try-scoped consts.
604
+ let baseConversationId: string | undefined;
605
+ let conversationId: string | undefined;
606
+ let usageState: UsageState | undefined;
590
607
  try {
591
608
  const apiKey = options?.apiKey;
592
609
  if (!apiKey) {
593
610
  throw new AIError.MissingApiKeyError(undefined, "Cursor API key (access token) is required");
594
611
  }
595
612
 
596
- const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
613
+ baseConversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
614
+ conversationId = rotatedConversationIds.get(baseConversationId) ?? baseConversationId;
597
615
  const blobStore = conversationBlobStores.get(conversationId) ?? new Map<string, Uint8Array>();
598
616
  conversationBlobStores.set(conversationId, blobStore);
599
617
  const cachedState = conversationStateCache.get(conversationId);
@@ -667,7 +685,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
667
685
  let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
668
686
  let currentToolCall: ToolCallState | null = null;
669
687
  const resolvedMcpToolCallIds = new Set<string>();
670
- const usageState: UsageState = { sawTokenDelta: false };
688
+ usageState = { sawTokenDelta: false };
671
689
 
672
690
  const state: BlockState = {
673
691
  get currentTextBlock() {
@@ -702,7 +720,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
702
720
  openBlockState = state;
703
721
 
704
722
  const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
705
- conversationStateCache.set(conversationId, checkpoint);
723
+ conversationStateCache.set(conversationId!, checkpoint);
706
724
  };
707
725
 
708
726
  h2Request.on("response", headers => {
@@ -759,7 +777,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
759
777
  h2Request!,
760
778
  options?.execHandlers,
761
779
  options?.onToolResult,
762
- usageState,
780
+ usageState!,
763
781
  requestContextTools,
764
782
  onConversationCheckpoint,
765
783
  ).catch(error => {
@@ -873,6 +891,28 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
873
891
  flushOpenToolCalls(output, stream, openBlockState);
874
892
  }
875
893
  const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
894
+ // #8345: a server-side per-conversation rejection surfaces as a bare
895
+ // resource_exhausted with zero tokens — the conversation is poisoned,
896
+ // not the account (sibling conversations keep working). Rotate the
897
+ // wire id once and migrate the cached state so the next attempt (the
898
+ // caller's retry loop) starts a fresh conversation, exactly like
899
+ // /fork. Only the first failure rotates; repeated failures keep the
900
+ // rotated id so a genuine account-level exhaustion is not hidden.
901
+ if (
902
+ conversationId !== undefined &&
903
+ baseConversationId !== undefined &&
904
+ usageState !== undefined &&
905
+ !usageState.sawTokenDelta &&
906
+ RESOURCE_EXHAUSTED_PATTERN.test(result.message) &&
907
+ !rotatedConversationIds.has(baseConversationId)
908
+ ) {
909
+ const rotated = crypto.randomUUID();
910
+ rotatedConversationIds.set(baseConversationId, rotated);
911
+ const state = conversationStateCache.get(conversationId);
912
+ if (state) conversationStateCache.set(rotated, state);
913
+ const blobs = conversationBlobStores.get(conversationId);
914
+ if (blobs) conversationBlobStores.set(rotated, blobs);
915
+ }
876
916
  output.stopReason = result.stopReason;
877
917
  output.errorStatus = result.status;
878
918
  output.errorId = result.id;