@bitkyc08/opencodex 2.52.0 → 2.53.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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,1153 @@
1
+ import { decodeEventStream } from "../../lib/eventstream-decoder";
2
+ import { debugProviderDiagnostic } from "../../lib/debug";
3
+ import {
4
+ isTranslatorBudgetExceededError,
5
+ type TranslatorBudget,
6
+ } from "../../lib/translator-budget";
7
+ import type { AdapterEvent, OcxUsage } from "../../types";
8
+ import { recordKiroCalibration, rekeyKiroCalibration } from "../kiro-calibration";
9
+ import { KIRO_COMPLETION_TOOL_NAME, type KiroCompletionMode } from "../kiro-constants";
10
+ import {
11
+ classifyKiroEventError,
12
+ classifyKiroHttpError,
13
+ classifyKiroStreamError,
14
+ safeKiroErrorMessage,
15
+ type KiroErrorClassification,
16
+ } from "../kiro-errors";
17
+ import { parseKiroEvent } from "../kiro-events";
18
+ import { noteKiroTransientThrottle } from "../kiro-retry";
19
+ import { KiroThinkingParser } from "../kiro-thinking";
20
+ import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation";
21
+ import { isValidKiroConversationId } from "../kiro-wire";
22
+ import { estimateKiroTokens, kiroUpstreamContextWindow } from "./usage";
23
+
24
+ // Stream parsing (shared by parseStream + parseResponse)
25
+ // CodeWhisperer GenerateAssistantResponse ALWAYS returns an AWS eventstream body (there is no
26
+ // non-streaming wire mode), so the streaming bridge and non-streaming Responses path decode the
27
+ // same way — parseResponse just collects what parseStream yields.
28
+ interface KiroAttemptParseResult {
29
+ terminal?: AdapterEvent;
30
+ needsFallback?: boolean;
31
+ usage?: OcxUsage;
32
+ providerState?: { kiro: { conversationId: string } };
33
+ assistantText: string;
34
+ sawReasoning: boolean;
35
+ }
36
+
37
+ interface KiroAttemptResult extends KiroAttemptParseResult {
38
+ releaseRetained(): void;
39
+ }
40
+
41
+ interface KiroAttemptRetention {
42
+ trackReplacement(previousBytes: number, nextBytes: number): void;
43
+ retainEvent(event: AdapterEvent, bytes: number): void;
44
+ releaseEvent(event: AdapterEvent): void;
45
+ releaseAll(): void;
46
+ }
47
+
48
+ function createKiroAttemptRetention(budget: TranslatorBudget): KiroAttemptRetention {
49
+ let retainedBytes = 0;
50
+ const eventBytes = new Map<AdapterEvent, number>();
51
+ return {
52
+ trackReplacement(previousBytes, nextBytes) {
53
+ retainedBytes = Math.max(0, retainedBytes - previousBytes) + nextBytes;
54
+ },
55
+ retainEvent(event, bytes) {
56
+ retainedBytes += bytes;
57
+ eventBytes.set(event, bytes);
58
+ },
59
+ releaseEvent(event) {
60
+ const bytes = eventBytes.get(event);
61
+ if (bytes === undefined) return;
62
+ eventBytes.delete(event);
63
+ retainedBytes = Math.max(0, retainedBytes - bytes);
64
+ budget.releaseRetained(bytes, { kind: "retained_collectors" });
65
+ },
66
+ releaseAll() {
67
+ if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" });
68
+ retainedBytes = 0;
69
+ eventBytes.clear();
70
+ },
71
+ };
72
+ }
73
+
74
+ interface KiroFallbackAttempt {
75
+ response: Response;
76
+ inputTokens: number;
77
+ contextInputEstimate: number;
78
+ nameMap: Map<string, string>;
79
+ conversationId: string;
80
+ releaseRequestBody?: () => void;
81
+ }
82
+
83
+ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
84
+ let nextBytes = previousBytes + Buffer.byteLength(fragment);
85
+ const previousLast = previous.charCodeAt(previous.length - 1);
86
+ const fragmentFirst = fragment.charCodeAt(0);
87
+ if (previousLast >= 0xd800 && previousLast <= 0xdbff
88
+ && fragmentFirst >= 0xdc00 && fragmentFirst <= 0xdfff) {
89
+ nextBytes -= 2;
90
+ }
91
+ return nextBytes;
92
+ }
93
+
94
+ /** Exact UTF-8 size JSON.stringify() will use for a string, without materializing that copy. */
95
+ export function jsonStringSerializedUtf8Bytes(value: string): number {
96
+ let bytes = 2; // Opening and closing quotes.
97
+ for (let index = 0; index < value.length; index++) {
98
+ const code = value.charCodeAt(index);
99
+ if (code === 0x22 || code === 0x5c) {
100
+ bytes += 2;
101
+ } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
102
+ bytes += 2;
103
+ } else if (code < 0x20) {
104
+ bytes += 6;
105
+ } else if (code <= 0x7f) {
106
+ bytes += 1;
107
+ } else if (code <= 0x7ff) {
108
+ bytes += 2;
109
+ } else if (code >= 0xd800 && code <= 0xdbff) {
110
+ const next = value.charCodeAt(index + 1);
111
+ if (next >= 0xdc00 && next <= 0xdfff) {
112
+ bytes += 4;
113
+ index++;
114
+ } else {
115
+ bytes += 6;
116
+ }
117
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
118
+ bytes += 6;
119
+ } else {
120
+ bytes += 3;
121
+ }
122
+ }
123
+ return bytes;
124
+ }
125
+
126
+ interface KiroContextWindowState {
127
+ value?: number;
128
+ }
129
+
130
+ export type KiroFallbackFactory = (
131
+ conversationId: string | undefined,
132
+ assistantText: string,
133
+ sawReasoning: boolean,
134
+ budget: TranslatorBudget,
135
+ ) => Promise<KiroFallbackAttempt>;
136
+
137
+ function mergeKiroUsage(
138
+ first: OcxUsage | undefined,
139
+ second: OcxUsage | undefined,
140
+ preserveFirstContextGrowth = false,
141
+ ): OcxUsage | undefined {
142
+ if (!first) return second;
143
+ if (!second) return first;
144
+ const sumOptional = (key: keyof OcxUsage): number | undefined => {
145
+ const a = first[key];
146
+ const b = second[key];
147
+ return typeof a === "number" || typeof b === "number"
148
+ ? (typeof a === "number" ? a : 0) + (typeof b === "number" ? b : 0)
149
+ : undefined;
150
+ };
151
+ const totalTokens = typeof first.totalTokens === "number" && typeof second.totalTokens === "number"
152
+ ? first.totalTokens + second.totalTokens
153
+ : undefined;
154
+ const carriedContextTotal = preserveFirstContextGrowth && typeof first.contextTotalTokens === "number"
155
+ ? first.contextTotalTokens + second.outputTokens
156
+ : undefined;
157
+ const combinedOutputTokens = first.outputTokens + second.outputTokens;
158
+ return {
159
+ inputTokens: first.inputTokens + second.inputTokens,
160
+ outputTokens: combinedOutputTokens,
161
+ ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number"
162
+ ? {
163
+ contextTotalTokens: Math.max(
164
+ first.contextTotalTokens ?? 0,
165
+ second.contextTotalTokens ?? 0,
166
+ carriedContextTotal ?? 0,
167
+ combinedOutputTokens,
168
+ ),
169
+ }
170
+ : {}),
171
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
172
+ ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}),
173
+ ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}),
174
+ ...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}),
175
+ ...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}),
176
+ ...(first.estimated || second.estimated ? { estimated: true } : {}),
177
+ };
178
+ }
179
+
180
+ function retryableKiroIncomplete(
181
+ reason: string,
182
+ message: string,
183
+ usage: OcxUsage,
184
+ providerState: { kiro: { conversationId: string } } | undefined,
185
+ retryable = true,
186
+ ): AdapterEvent {
187
+ return {
188
+ type: "incomplete",
189
+ reason,
190
+ message,
191
+ usage,
192
+ retryable,
193
+ endTurn: false,
194
+ ...(providerState ? { providerState } : {}),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Catch-path retryability for #519: only transport/socket failures with no emitted output
200
+ * are replay-safe. Malformed event payloads (`invalid Kiro …`) and any post-output failure
201
+ * stay terminal — same spirit as cursor's emittedOutput gate.
202
+ */
203
+ export function isRetryableKiroStreamCatchError(err: unknown, emittedOutput: boolean): boolean {
204
+ if (emittedOutput) return false;
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ if (/^invalid Kiro\b/i.test(message)) return false;
207
+ // Include Smithy/eventstream truncation (`eventstream: truncated message at end of stream`):
208
+ // partial frame + clean EOF with zero output is the same replay-safe class as a socket close.
209
+ return /socket connection was closed|connection(?: was)? closed unexpectedly|ECONNRESET|EPIPE|UND_ERR_|fetch failed|decoder failed|premature close|other side closed|unexpected EOF|network connection lost|terminated|truncated message at end of stream|eventstream:\s*truncated/i
210
+ .test(message);
211
+ }
212
+
213
+ /** Native clean-stop reason eligible for bounded private-completion validation. */
214
+ const KIRO_END_TURN_STOP_REASON = "END_TURN";
215
+
216
+ async function* parseKiroAttempt(
217
+ response: Response,
218
+ budget: TranslatorBudget,
219
+ mode: KiroCompletionMode,
220
+ modelId: string | undefined,
221
+ inputTokens: number,
222
+ contextWindowState: KiroContextWindowState,
223
+ nameMap: Map<string, string> | undefined,
224
+ conversationId: string | undefined,
225
+ contextInputEstimate?: number,
226
+ /** True when an earlier attempt already flushed visible content to the client (#520). */
227
+ priorEmittedOutput = false,
228
+ ): AsyncGenerator<AdapterEvent, KiroAttemptResult> {
229
+ // `required` mode holds staged commentary until a real tool call or terminal metadata identifies
230
+ // the attempt boundary. Anything the inner parser leaves behind is flushed before the terminal.
231
+ const deferred: AdapterEvent[] = [];
232
+ const retention = createKiroAttemptRetention(budget);
233
+ // Shared box: the inner parser stages its calibration observation here on the completion path,
234
+ // and this wrapper decides whether the attempt was terminal enough to commit it. A box rather
235
+ // than a return field because the completion path has a dozen terminal returns and threading a
236
+ // field through every one of them is exactly the kind of edit that misses one.
237
+ const attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } } = {};
238
+ const attempt = parseKiroAttemptEvents(
239
+ response,
240
+ budget,
241
+ mode,
242
+ modelId,
243
+ inputTokens,
244
+ contextWindowState,
245
+ nameMap,
246
+ conversationId,
247
+ deferred,
248
+ retention,
249
+ attemptCalibration,
250
+ contextInputEstimate,
251
+ priorEmittedOutput,
252
+ );
253
+ let handedOff = false;
254
+ try {
255
+ const result = yield* attempt;
256
+ // A staged observation only counts when this attempt is the LAST one for the user turn. An
257
+ // attempt that asks for the bounded fallback streams again against a rebuilt payload, so
258
+ // committing here would move the factor twice for one turn and score the second observation
259
+ // against a payload the first had already inflated.
260
+ const staged = attemptCalibration.value;
261
+ attemptCalibration.value = undefined;
262
+ if (staged && !result.needsFallback) {
263
+ recordKiroCalibration(staged.conversationId, staged.estimated, staged.charged);
264
+ }
265
+ for (const event of deferred.splice(0)) {
266
+ try { yield event; } finally { retention.releaseEvent(event); }
267
+ }
268
+ handedOff = true;
269
+ return { ...result, releaseRetained: () => retention.releaseAll() };
270
+ } finally {
271
+ if (!handedOff) retention.releaseAll();
272
+ }
273
+ }
274
+
275
+ async function* parseKiroAttemptEvents(
276
+ response: Response,
277
+ budget: TranslatorBudget,
278
+ mode: KiroCompletionMode,
279
+ modelId: string | undefined,
280
+ inputTokens: number,
281
+ contextWindowState: KiroContextWindowState,
282
+ nameMap: Map<string, string> | undefined,
283
+ conversationId: string | undefined,
284
+ deferred: AdapterEvent[],
285
+ retention: KiroAttemptRetention,
286
+ attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } },
287
+ contextInputEstimate?: number,
288
+ priorEmittedOutput = false,
289
+ ): AsyncGenerator<AdapterEvent, KiroAttemptParseResult> {
290
+ const emptyResult = (): KiroAttemptParseResult => ({ assistantText: "", sawReasoning: false });
291
+ // Every early return below is a failure path that stages nothing; only the completion path
292
+ // writes `attemptCalibration`, and the wrapper decides whether to commit it.
293
+ if (!response.body) {
294
+ return {
295
+ ...emptyResult(),
296
+ terminal: { type: "error", message: "Kiro response has no body", status: 502, errorType: "upstream_error" },
297
+ };
298
+ }
299
+
300
+ let open: { id: string; name: string; chunks: string[]; completion: boolean } | null = null;
301
+ let openCallId: string | undefined;
302
+ const closeOpenCall = () => {
303
+ if (!openCallId) return;
304
+ budget.closeCall(openCallId);
305
+ openCallId = undefined;
306
+ };
307
+ let outputChars = "";
308
+ let outputCharsBytes = 0;
309
+ let contextUsagePercentage: number | undefined;
310
+ let returnedConversationId = conversationId;
311
+ let assistantText = "";
312
+ let assistantTextBytes = 0;
313
+ let sawText = false;
314
+ let sawReasoning = false;
315
+ let sawRealTool = false;
316
+ let completionAnswer: string | undefined;
317
+ let completionCalls = 0;
318
+ let authoritativeUsage: OcxUsage | undefined;
319
+ let stopReason: string | undefined;
320
+ const fallbackEvents: AdapterEvent[] = [];
321
+ const thinking = new KiroThinkingParser(budget);
322
+
323
+ const retainedEventBytes = (event: AdapterEvent): number => Buffer.byteLength(JSON.stringify(event));
324
+ const retainEvent = (event: AdapterEvent): void => {
325
+ const bytes = retainedEventBytes(event);
326
+ budget.chargeRetained(bytes, { kind: "retained_collectors" });
327
+ retention.retainEvent(event, bytes);
328
+ };
329
+ const emitRetained = async function* (events: Iterable<AdapterEvent>): AsyncGenerator<AdapterEvent> {
330
+ for (const event of events) {
331
+ try { yield event; } finally { retention.releaseEvent(event); }
332
+ }
333
+ };
334
+ // A valid private completion answer supersedes the progress prose staged during the SAME
335
+ // inference: Kiro emits answer-like text and then calls the completion tool, so releasing both
336
+ // makes the bridge close the commentary message and open a second one with near-identical text
337
+ // (#2819 follow-up). Consume the collection instead — drop the redundant text, keep every
338
+ // non-text event, and release retention either way.
339
+ //
340
+ // This is deliberately the ONLY suppression site. The outer drain in `parseKiroAttempt` is also
341
+ // the leftover flush for early terminal returns (stream, protocol, and provider failures), so
342
+ // teaching it to discard text would hide the only commentary a failed turn ever produced.
343
+ // Splicing here leaves that drain empty on the completion path and untouched everywhere else.
344
+ const consumeSupersededByCompletion = async function* (
345
+ events: AdapterEvent[],
346
+ ): AsyncGenerator<AdapterEvent> {
347
+ for (const event of events.splice(0)) {
348
+ try {
349
+ if (event.type !== "text_delta") yield event;
350
+ } finally {
351
+ retention.releaseEvent(event);
352
+ }
353
+ }
354
+ };
355
+
356
+ const providerState = (): { kiro: { conversationId: string } } | undefined =>
357
+ returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined;
358
+
359
+ const contextUsageTotalFloor = (): number | undefined => {
360
+ if (contextUsagePercentage === undefined || !contextWindowState.value) return undefined;
361
+ const floor = Math.ceil(contextWindowState.value * Math.min(contextUsagePercentage, 100) / 100);
362
+ return Number.isFinite(floor) && floor > 0 ? floor : undefined;
363
+ };
364
+ const usage = (): OcxUsage => {
365
+ const base = authoritativeUsage ?? {
366
+ inputTokens,
367
+ outputTokens: estimateKiroTokens(outputChars, modelId),
368
+ estimated: true,
369
+ };
370
+ const estimatedContextTotal = contextInputEstimate !== undefined
371
+ ? contextInputEstimate + base.outputTokens
372
+ : undefined;
373
+ const authoritativeTurnTotal = base.inputTokens + base.outputTokens;
374
+ const contextTotal = Math.max(
375
+ estimatedContextTotal ?? 0,
376
+ contextUsageTotalFloor() ?? 0,
377
+ authoritativeTurnTotal,
378
+ );
379
+ return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base;
380
+ };
381
+
382
+ const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => {
383
+ // Upstream exception/error frames can arrive after commentary was already staged (and will be
384
+ // flushed before this terminal is yielded). Replaying after that content would duplicate it.
385
+ const emittedOutput = priorEmittedOutput
386
+ || sawText
387
+ || sawReasoning
388
+ || sawRealTool
389
+ || assistantText.length > 0
390
+ || deferred.length > 0
391
+ || completionAnswer !== undefined
392
+ || completionCalls > 0
393
+ || open !== null
394
+ || fallbackEvents.length > 0;
395
+ if (failure.status === 429 && failure.retryable) noteKiroTransientThrottle();
396
+ return {
397
+ type: "error",
398
+ message: failure.message,
399
+ status: failure.status,
400
+ errorType: failure.errorType,
401
+ code: failure.code,
402
+ retryable: emittedOutput ? false : failure.retryable,
403
+ usage: usage(),
404
+ };
405
+ };
406
+
407
+ const protocolTerminal = (message: string, malformedCompletion = false): AdapterEvent => {
408
+ if (mode === "text_fallback" && malformedCompletion) {
409
+ return retryableKiroIncomplete(
410
+ "malformed_kiro_completion",
411
+ message,
412
+ usage(),
413
+ providerState(),
414
+ // First-attempt progress was already flushed before this bounded fallback (#520).
415
+ !priorEmittedOutput,
416
+ );
417
+ }
418
+ return {
419
+ type: "error",
420
+ message,
421
+ status: 502,
422
+ errorType: "upstream_error",
423
+ code: malformedCompletion ? "invalid_kiro_completion" : "kiro_stream_protocol_error",
424
+ retryable: false,
425
+ usage: usage(),
426
+ };
427
+ };
428
+
429
+ const classifyTool = (
430
+ tool: { id: string; name: string; chunks: string[]; completion: boolean },
431
+ ): AdapterEvent | undefined => {
432
+ if (tool.name !== KIRO_COMPLETION_TOOL_NAME) {
433
+ tool.completion = false;
434
+ return completionAnswer !== undefined || completionCalls > 0
435
+ ? protocolTerminal("Kiro returned a real tool call alongside a private final answer")
436
+ : undefined;
437
+ }
438
+ if (mode === "disabled") {
439
+ return protocolTerminal("Kiro returned the reserved private final-answer tool while explicit completion was disabled");
440
+ }
441
+ tool.completion = true;
442
+ if (completionAnswer !== undefined || completionCalls > 0) {
443
+ return protocolTerminal("Kiro returned more than one private final-answer tool call", true);
444
+ }
445
+ if (sawRealTool) {
446
+ return protocolTerminal("Kiro returned a private final answer alongside a real tool call");
447
+ }
448
+ return undefined;
449
+ };
450
+
451
+ const beginTool = (
452
+ id: string,
453
+ name: string,
454
+ ): { tool?: { id: string; name: string; chunks: string[]; completion: boolean }; terminal?: AdapterEvent } => {
455
+ const next = { id, name, chunks: [], completion: false };
456
+ const terminal = classifyTool(next);
457
+ return terminal ? { terminal } : { tool: next };
458
+ };
459
+
460
+ // In `required` mode Kiro's stop reason only arrives on the terminal metadata event, so staged
461
+ // commentary is held until either a real tool call proves the turn continues (flush as
462
+ // commentary) or the stream ends (relabel as the final answer when END_TURN says so). A heartbeat
463
+ // stands in for each held event so the bridge's stall watchdog stays armed.
464
+ const defer = (event: AdapterEvent): AdapterEvent[] => {
465
+ if (sawRealTool) return [...deferred.splice(0), event];
466
+ if (event.type !== "text_delta" && deferred.length === 0) return [event];
467
+ deferred.push(event);
468
+ retainEvent(event);
469
+ return [{ type: "heartbeat" }];
470
+ };
471
+
472
+ const stage = (event: AdapterEvent): AdapterEvent[] => {
473
+ if (event.type === "text_delta") {
474
+ const nextAssistantTextBytes = appendedUtf8Bytes(assistantText, assistantTextBytes, event.text);
475
+ const assistantReservation = budget.reserveTransient(nextAssistantTextBytes, { kind: "retained_collectors" });
476
+ assistantText += event.text;
477
+ assistantReservation.commitRetained();
478
+ budget.releaseRetained(assistantTextBytes, { kind: "retained_collectors" });
479
+ retention.trackReplacement(assistantTextBytes, nextAssistantTextBytes);
480
+ assistantTextBytes = nextAssistantTextBytes;
481
+ if (event.text.trim()) sawText = true;
482
+ const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, event.text);
483
+ const outputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" });
484
+ outputChars += event.text;
485
+ outputReservation.commitRetained();
486
+ budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" });
487
+ retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes);
488
+ outputCharsBytes = nextOutputCharsBytes;
489
+ const phased = mode === "disabled"
490
+ ? event
491
+ : { ...event, phase: "commentary" as const };
492
+ if (mode === "text_fallback") {
493
+ fallbackEvents.push(phased);
494
+ retainEvent(phased);
495
+ return [];
496
+ }
497
+ return mode === "required" ? defer(phased) : [phased];
498
+ }
499
+ if (event.type === "reasoning_raw_delta" || event.type === "thinking_delta") {
500
+ const text = event.type === "reasoning_raw_delta" ? event.text : event.thinking;
501
+ if (text.trim()) sawReasoning = true;
502
+ const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, text);
503
+ const reasoningReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" });
504
+ outputChars += text;
505
+ reasoningReservation.commitRetained();
506
+ budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" });
507
+ retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes);
508
+ outputCharsBytes = nextOutputCharsBytes;
509
+ }
510
+ if (mode === "text_fallback" && event.type !== "heartbeat") {
511
+ fallbackEvents.push(event);
512
+ retainEvent(event);
513
+ return [];
514
+ }
515
+ return mode === "required" ? defer(event) : [event];
516
+ };
517
+
518
+ const parseCompletion = (chunks: string[]): string | Error => {
519
+ const raw = chunks.join("").trim();
520
+ let value: unknown;
521
+ try {
522
+ value = JSON.parse(raw || "{}");
523
+ } catch {
524
+ return new Error("Kiro returned invalid JSON for the private final-answer tool");
525
+ }
526
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
527
+ return new Error("Kiro returned a non-object value for the private final-answer tool");
528
+ }
529
+ const answer = (value as { answer?: unknown }).answer;
530
+ if (typeof answer !== "string" || !answer.trim()) {
531
+ return new Error("Kiro returned an empty final answer");
532
+ }
533
+ return answer;
534
+ };
535
+
536
+ const flushOpen = (): { events: AdapterEvent[]; terminal?: AdapterEvent } => {
537
+ if (!open) return { events: [] };
538
+ const tool = open;
539
+ open = null;
540
+ closeOpenCall();
541
+ const input = tool.chunks.join("");
542
+ if (!isCompleteKiroToolInput(input)) {
543
+ return { events: [], terminal: protocolTerminal(kiroTruncationErrorMessage("incomplete tool input JSON"), tool.completion) };
544
+ }
545
+ if (tool.completion) {
546
+ completionCalls++;
547
+ if (completionCalls > 1) {
548
+ return { events: [], terminal: protocolTerminal("Kiro returned more than one private final-answer tool call", true) };
549
+ }
550
+ if (sawRealTool) {
551
+ return { events: [], terminal: protocolTerminal("Kiro returned a private final answer alongside a real tool call") };
552
+ }
553
+ const answer = parseCompletion(tool.chunks);
554
+ if (answer instanceof Error) return { events: [], terminal: protocolTerminal(answer.message, true) };
555
+ completionAnswer = answer;
556
+ return { events: [] };
557
+ }
558
+ if (completionAnswer !== undefined || completionCalls > 0) {
559
+ return { events: [], terminal: protocolTerminal("Kiro returned a real tool call alongside a private final answer") };
560
+ }
561
+ sawRealTool = true;
562
+ const restored = nameMap?.get(tool.name) ?? tool.name;
563
+ return {
564
+ events: [
565
+ { type: "tool_call_start", id: tool.id, name: restored },
566
+ ...tool.chunks.filter(Boolean).map(argumentsChunk => ({ type: "tool_call_delta", arguments: argumentsChunk }) as AdapterEvent),
567
+ { type: "tool_call_end" },
568
+ ],
569
+ };
570
+ };
571
+
572
+ try {
573
+ for await (const msg of decodeEventStream(response.body)) {
574
+ const mt = msg.headers[":message-type"];
575
+ if (mt === "exception" || mt === "error") {
576
+ open = null;
577
+ return {
578
+ assistantText,
579
+ sawReasoning,
580
+ terminal: classifiedTerminal(classifyKiroStreamError(msg.headers, new TextDecoder().decode(msg.payload))),
581
+ };
582
+ }
583
+ if (mt !== "event") {
584
+ open = null;
585
+ return {
586
+ assistantText,
587
+ sawReasoning,
588
+ terminal: protocolTerminal(`Kiro response protocol error: unsupported Smithy message type ${JSON.stringify(mt ?? "missing")}`),
589
+ };
590
+ }
591
+ const eventType = msg.headers[":event-type"];
592
+ if (!eventType) {
593
+ open = null;
594
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: event is missing :event-type") };
595
+ }
596
+ const ev = parseKiroEvent(eventType, msg.payload);
597
+ if (!ev) continue;
598
+ switch (ev.type) {
599
+ case "metadata":
600
+ if (ev.usage) authoritativeUsage = ev.usage;
601
+ if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) {
602
+ contextUsagePercentage = ev.contextUsagePercentage;
603
+ }
604
+ if (ev.stopReason !== undefined) stopReason = ev.stopReason;
605
+ break;
606
+ case "message_metadata":
607
+ if (isValidKiroConversationId(ev.conversationId)) {
608
+ // Kiro can answer under a different conversation id than the request was built with.
609
+ // Carry the calibration entry across so the record below finds its own raw estimate
610
+ // instead of silently falling back to the already-corrected value.
611
+ rekeyKiroCalibration(returnedConversationId, ev.conversationId);
612
+ returnedConversationId = ev.conversationId;
613
+ }
614
+ break;
615
+ case "content":
616
+ if (ev.modelId) {
617
+ contextWindowState.value = kiroUpstreamContextWindow(ev.modelId) ?? contextWindowState.value;
618
+ }
619
+ if (open) {
620
+ open = null;
621
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("content arrived before tool stop")) };
622
+ }
623
+ if (ev.data) {
624
+ for (const contentEvent of thinking.feed(ev.data)) {
625
+ yield* emitRetained(stage(contentEvent));
626
+ }
627
+ }
628
+ break;
629
+ case "reasoning":
630
+ for (const contentEvent of thinking.flush()) {
631
+ yield* emitRetained(stage(contentEvent));
632
+ }
633
+ if (ev.data) {
634
+ yield* emitRetained(stage({ type: "reasoning_raw_delta", text: ev.data }));
635
+ }
636
+ if (ev.redactedContent) {
637
+ yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: ev.redactedContent }));
638
+ }
639
+ break;
640
+ case "context_usage":
641
+ if (ev.contextUsagePercentage > 0) contextUsagePercentage = ev.contextUsagePercentage;
642
+ break;
643
+ case "tool": {
644
+ for (const contentEvent of thinking.flush()) {
645
+ yield* emitRetained(stage(contentEvent));
646
+ }
647
+ if (!open) {
648
+ if (ev.stop === true) {
649
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: tool stop received without an open tool call") };
650
+ }
651
+ if (!ev.toolUseId || !ev.name) {
652
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: new tool event is missing toolUseId or name") };
653
+ }
654
+ const started = beginTool(ev.toolUseId, ev.name);
655
+ if (started.terminal) return { assistantText, sawReasoning, terminal: started.terminal };
656
+ open = started.tool!;
657
+ budget.openCall(open.id);
658
+ openCallId = open.id;
659
+ } else if (
660
+ (ev.toolUseId && ev.toolUseId !== open.id)
661
+ || (ev.name && open.name !== "unknown" && ev.name !== open.name)
662
+ ) {
663
+ closeOpenCall();
664
+ open = null;
665
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("tool input changed identity before stop")) };
666
+ }
667
+ if (open && open.name === "unknown" && ev.name) {
668
+ open.name = ev.name;
669
+ const terminal = classifyTool(open);
670
+ if (terminal) {
671
+ open = null;
672
+ return { assistantText, sawReasoning, terminal };
673
+ }
674
+ }
675
+ if (open && ev.input !== undefined) {
676
+ const previousCallBytes = open.chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk), 0);
677
+ const nextCallBytes = previousCallBytes + Buffer.byteLength(ev.input);
678
+ const callReservation = budget.reserveTransient(nextCallBytes, { kind: "tool_args", callId: open.id });
679
+ open.chunks.push(ev.input);
680
+ callReservation.commitRetained();
681
+ budget.releaseRetained(previousCallBytes, { kind: "tool_args", callId: open.id });
682
+ const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, ev.input);
683
+ const toolOutputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" });
684
+ outputChars += ev.input;
685
+ toolOutputReservation.commitRetained();
686
+ budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" });
687
+ retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes);
688
+ outputCharsBytes = nextOutputCharsBytes;
689
+ }
690
+ if (ev.stop === true) {
691
+ const flushed = flushOpen();
692
+ if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal };
693
+ for (const event of flushed.events) {
694
+ yield* emitRetained(stage(event));
695
+ }
696
+ } else {
697
+ yield { type: "heartbeat" };
698
+ }
699
+ break;
700
+ }
701
+ case "invalid_state":
702
+ open = null;
703
+ return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(undefined, ev.message ?? "Kiro entered an invalid state")) };
704
+ case "error":
705
+ open = null;
706
+ return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(ev.reason, ev.message)) };
707
+ case "truncation":
708
+ open = null;
709
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage(ev.data)) };
710
+ }
711
+ }
712
+
713
+ for (const contentEvent of thinking.flush()) {
714
+ yield* emitRetained(stage(contentEvent));
715
+ }
716
+ if (open) {
717
+ const input = open.chunks.join("");
718
+ if (!isCompleteKiroToolInput(input)) {
719
+ const privateTool = open.completion;
720
+ open = null;
721
+ return {
722
+ assistantText,
723
+ sawReasoning,
724
+ terminal: protocolTerminal(kiroTruncationErrorMessage("stream ended before tool stop"), privateTool),
725
+ };
726
+ }
727
+ const flushed = flushOpen();
728
+ if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal };
729
+ for (const event of flushed.events) {
730
+ yield* emitRetained(stage(event));
731
+ }
732
+ }
733
+
734
+ const finalUsage = usage();
735
+ const finalProviderState = providerState();
736
+ if (contextUsagePercentage !== undefined) {
737
+ debugProviderDiagnostic("kiro", "context_usage", {
738
+ contextUsagePercentage,
739
+ ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}),
740
+ });
741
+ }
742
+ // Upstream just told us what this payload cost. The ratio between that and our pre-request
743
+ // estimate is this conversation's own measured error, and it is the only feedback the
744
+ // estimator ever receives.
745
+ //
746
+ // Staged, not recorded. An attempt that sets `needsFallback` is not over: the adapter rebuilds
747
+ // the payload and streams a second time for the SAME user turn. Learning here would apply the
748
+ // fresh factor to that rebuild and then learn again from it, so one turn would move the factor
749
+ // twice and the second observation would score a payload the first had already inflated. Only
750
+ // the outer parser knows whether an attempt is terminal, so it commits.
751
+ //
752
+ // Subtract the output first. `contextUsageTotalFloor` is the absolute context size AFTER the
753
+ // response (`OcxUsage.contextTotalTokens`, types/request.ts), while `contextInputEstimate`
754
+ // covers the request payload alone. Dividing one by the other would charge generated tokens to
755
+ // prompt-tokenization error, so a short prompt answered at length would learn a large factor
756
+ // and inflate every later request in that conversation — the premature compaction this work
757
+ // exists to prevent.
758
+ const chargedTotal = contextUsageTotalFloor();
759
+ if (chargedTotal !== undefined && contextInputEstimate !== undefined) {
760
+ const chargedInput = chargedTotal - finalUsage.outputTokens;
761
+ if (chargedInput > 0 && returnedConversationId) {
762
+ attemptCalibration.value = { conversationId: returnedConversationId, estimated: contextInputEstimate, charged: chargedInput };
763
+ }
764
+ }
765
+ // Native stop metadata proves that this inference ended, but it does not prove that ordinary
766
+ // text is a final answer. Kiro has emitted END_TURN for progress prose, so tool-enabled turns
767
+ // still require the private completion call to distinguish commentary from completion (#531).
768
+ const normalizedStopReason = stopReason?.trim().toUpperCase();
769
+ const nativeCompletionStop = (normalizedStopReason === KIRO_END_TURN_STOP_REASON
770
+ || normalizedStopReason === "STOP_SEQUENCE")
771
+ && sawText
772
+ && !sawRealTool
773
+ && completionAnswer === undefined
774
+ && completionCalls === 0;
775
+
776
+ debugProviderDiagnostic("kiro", "attempt_complete", {
777
+ mode,
778
+ sawText,
779
+ sawReasoning,
780
+ sawRealTool,
781
+ completionCalls,
782
+ nativeCompletionStop,
783
+ ...(stopReason !== undefined ? { stopReason } : {}),
784
+ assistantChars: assistantText.length,
785
+ });
786
+
787
+ if (mode === "required") {
788
+ // A valid completion answer makes this inference's staged prose redundant; anything else
789
+ // still flushes exactly as before (bounded fallback, explicit stops, real tool calls).
790
+ if (completionAnswer !== undefined) yield* consumeSupersededByCompletion(deferred);
791
+ else yield* emitRetained(deferred.splice(0));
792
+ }
793
+
794
+ if (mode === "text_fallback") {
795
+ if (completionAnswer !== undefined) {
796
+ yield* consumeSupersededByCompletion(fallbackEvents);
797
+ yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
798
+ return {
799
+ assistantText,
800
+ sawReasoning,
801
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
802
+ };
803
+ }
804
+ if (sawRealTool) {
805
+ yield* emitRetained(fallbackEvents);
806
+ return {
807
+ assistantText,
808
+ sawReasoning,
809
+ terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
810
+ };
811
+ }
812
+ if (sawText) {
813
+ for (const event of fallbackEvents) {
814
+ try {
815
+ if (event.type !== "text_delta") yield event;
816
+ else yield { ...event, phase: "final_answer" };
817
+ } finally {
818
+ retention.releaseEvent(event);
819
+ }
820
+ }
821
+ return {
822
+ assistantText,
823
+ sawReasoning,
824
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
825
+ };
826
+ }
827
+ yield* emitRetained(fallbackEvents);
828
+ return {
829
+ assistantText,
830
+ sawReasoning,
831
+ terminal: retryableKiroIncomplete(
832
+ sawReasoning ? "reasoning_only_kiro_fallback" : "empty_kiro_fallback",
833
+ sawReasoning
834
+ ? "Kiro produced reasoning but no final answer on its bounded completion retry"
835
+ : "Kiro produced no final answer on its bounded completion retry",
836
+ finalUsage,
837
+ finalProviderState,
838
+ // First-attempt progress was already flushed before this bounded fallback (#520).
839
+ !priorEmittedOutput,
840
+ ),
841
+ };
842
+ }
843
+
844
+ if (completionAnswer !== undefined) {
845
+ yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
846
+ return {
847
+ assistantText,
848
+ sawReasoning,
849
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
850
+ };
851
+ }
852
+ if (sawRealTool) {
853
+ return {
854
+ assistantText,
855
+ sawReasoning,
856
+ terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
857
+ };
858
+ }
859
+ if (mode === "required" && nativeCompletionStop) {
860
+ return {
861
+ assistantText,
862
+ sawReasoning,
863
+ needsFallback: true,
864
+ usage: finalUsage,
865
+ providerState: finalProviderState,
866
+ };
867
+ }
868
+
869
+ // An explicit non-completion stop reason has already terminated this inference. Converting it into
870
+ // another model request would hide truncation behind a second paid call, and for context
871
+ // exhaustion it would resubmit a request that cannot fit. Only a MISSING stop reason falls
872
+ // through to the bounded compatibility fallback below.
873
+ //
874
+ // END_TURN and STOP_SEQUENCE with text take the bounded validation path above; reaching here
875
+ // with either means the turn produced no replayable text.
876
+ if (mode === "required" && normalizedStopReason !== undefined) {
877
+ const providerStateField = finalProviderState ? { providerState: finalProviderState } : {};
878
+ const incomplete = (reason: string, retryable: boolean) => ({
879
+ assistantText,
880
+ sawReasoning,
881
+ terminal: {
882
+ type: "incomplete" as const,
883
+ reason,
884
+ message: `Kiro stopped with ${normalizedStopReason} before an explicit final answer`,
885
+ usage: finalUsage,
886
+ retryable,
887
+ endTurn: false,
888
+ ...providerStateField,
889
+ },
890
+ });
891
+
892
+ if (normalizedStopReason === "MODEL_CONTEXT_WINDOW_EXCEEDED") {
893
+ // Reuse the existing context-length contract (kiro-errors.ts) instead of inventing an
894
+ // incomplete reason: an unrecognized incomplete becomes a retryable 529 in Claude
895
+ // outbound, and `max_output_tokens` would make responses/state.ts cache this partial
896
+ // for continuation replay. Both invite a retry that cannot succeed.
897
+ return {
898
+ assistantText,
899
+ sawReasoning,
900
+ terminal: {
901
+ type: "error" as const,
902
+ message: "Kiro stopped because the model context window was exhausted",
903
+ status: 400,
904
+ errorType: "invalid_request_error",
905
+ code: "context_length_exceeded",
906
+ retryable: false,
907
+ usage: finalUsage,
908
+ },
909
+ };
910
+ }
911
+ if (normalizedStopReason === "MAX_TOKENS") return incomplete("max_output_tokens", true);
912
+ if (normalizedStopReason === "CONTENT_FILTERED" || normalizedStopReason === "GUARDRAIL_INTERVENED") {
913
+ return incomplete("content_filter", false);
914
+ }
915
+ if (normalizedStopReason === "MALFORMED_TOOL_USE") return incomplete("kiro_malformed_tool_use", false);
916
+ if (normalizedStopReason === "MALFORMED_MODEL_OUTPUT") return incomplete("kiro_malformed_model_output", false);
917
+ // TOOL_USE here means Kiro claimed a tool call it never emitted.
918
+ if (normalizedStopReason === "TOOL_USE") return incomplete("kiro_tool_use_without_call", false);
919
+ if (normalizedStopReason === KIRO_END_TURN_STOP_REASON || normalizedStopReason === "STOP_SEQUENCE") {
920
+ return incomplete(`kiro_${normalizedStopReason.toLowerCase()}_without_text`, false);
921
+ }
922
+ return incomplete(`kiro_${normalizedStopReason.toLowerCase() || "unknown_stop"}`, false);
923
+ }
924
+ // Kiro text has no trustworthy final/progress marker. When completion is required, ordinary
925
+ // text and reasoning remain unfinished until the one bounded fallback validates the turn.
926
+ if (mode === "required" && (sawText || sawReasoning)) {
927
+ return { assistantText, sawReasoning, needsFallback: true, usage: finalUsage, providerState: finalProviderState };
928
+ }
929
+ if (!sawText && !sawReasoning) {
930
+ return {
931
+ assistantText,
932
+ sawReasoning,
933
+ terminal: retryableKiroIncomplete(
934
+ "empty_kiro_stream",
935
+ "Kiro returned a successful but empty response stream",
936
+ finalUsage,
937
+ finalProviderState,
938
+ ),
939
+ };
940
+ }
941
+ return {
942
+ assistantText,
943
+ sawReasoning,
944
+ terminal: {
945
+ type: "done",
946
+ usage: finalUsage,
947
+ endTurn: mode === "disabled" ? sawText : false,
948
+ ...(finalProviderState ? { providerState: finalProviderState } : {}),
949
+ },
950
+ };
951
+ } catch (err) {
952
+ if (isTranslatorBudgetExceededError(err)) {
953
+ closeOpenCall();
954
+ return {
955
+ assistantText,
956
+ sawReasoning,
957
+ terminal: {
958
+ type: "error",
959
+ status: 502,
960
+ errorType: "upstream_error",
961
+ code: "translation_buffer_limit",
962
+ message: "upstream translation buffer exceeded the safe limit",
963
+ },
964
+ };
965
+ }
966
+ // Mid-stream socket closes after response.created / heartbeats only must stay retryable:
967
+ // nothing was relayed to the client, so a string-body replay is safe (see #519 / cursor's
968
+ // emittedOutput gate). Once any assistant text, reasoning, tool, or deferred content exists
969
+ // — including content flushed by a prior attempt before a bounded fallback — fail closed;
970
+ // the client may already have partial output. Protocol parse throws stay non-retryable even
971
+ // with zero output.
972
+ const emittedOutput = priorEmittedOutput
973
+ || sawText
974
+ || sawReasoning
975
+ || sawRealTool
976
+ || assistantText.length > 0
977
+ || deferred.length > 0
978
+ || completionAnswer !== undefined
979
+ || completionCalls > 0
980
+ || open !== null
981
+ || fallbackEvents.length > 0;
982
+ return {
983
+ assistantText,
984
+ sawReasoning,
985
+ terminal: {
986
+ type: "error",
987
+ message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)),
988
+ status: 502,
989
+ errorType: "server_error",
990
+ code: "kiro_stream_protocol_error",
991
+ retryable: isRetryableKiroStreamCatchError(err, emittedOutput),
992
+ usage: usage(),
993
+ },
994
+ };
995
+ } finally {
996
+ thinking.dispose();
997
+ closeOpenCall();
998
+ }
999
+ }
1000
+
1001
+ export async function* parseKiroStream(
1002
+ response: Response,
1003
+ budget: TranslatorBudget,
1004
+ modelId?: string,
1005
+ inputTokens = 0,
1006
+ contextWindow?: number,
1007
+ nameMap?: Map<string, string>,
1008
+ conversationId?: string,
1009
+ completionMode: KiroCompletionMode = "disabled",
1010
+ fallbackFactory?: KiroFallbackFactory,
1011
+ contextInputEstimate?: number,
1012
+ ): AsyncGenerator<AdapterEvent> {
1013
+ const contextWindowState: KiroContextWindowState = { value: contextWindow };
1014
+ const firstResult = yield* parseKiroAttempt(
1015
+ response,
1016
+ budget,
1017
+ completionMode,
1018
+ modelId,
1019
+ inputTokens,
1020
+ contextWindowState,
1021
+ nameMap,
1022
+ conversationId,
1023
+ contextInputEstimate,
1024
+ false,
1025
+ );
1026
+ try {
1027
+ if (!firstResult.needsFallback) {
1028
+ if (firstResult.terminal) yield firstResult.terminal;
1029
+ return;
1030
+ }
1031
+ if (!fallbackFactory) {
1032
+ yield retryableKiroIncomplete(
1033
+ "uncompleted_kiro_response",
1034
+ "Kiro produced progress without an explicit final answer and no bounded retry transport was available",
1035
+ firstResult.usage ?? { inputTokens, outputTokens: 0, estimated: true },
1036
+ firstResult.providerState,
1037
+ );
1038
+ return;
1039
+ }
1040
+
1041
+ yield { type: "heartbeat" };
1042
+ // First attempt already flushed deferred progress before this point. Gate fallback
1043
+ // setup/HTTP failures the same way as the second-stream catch so a replay cannot
1044
+ // duplicate visible commentary (#520).
1045
+ const priorEmittedOutput = Boolean(firstResult.assistantText.trim()) || firstResult.sawReasoning;
1046
+ let firstAssistantText = firstResult.assistantText;
1047
+ const firstHadAssistantText = firstAssistantText.length > 0;
1048
+ let fallback: KiroFallbackAttempt;
1049
+ try {
1050
+ fallback = await fallbackFactory(
1051
+ firstResult.providerState?.kiro.conversationId ?? conversationId,
1052
+ firstAssistantText,
1053
+ firstResult.sawReasoning,
1054
+ budget,
1055
+ );
1056
+ } catch (err) {
1057
+ firstAssistantText = "";
1058
+ firstResult.assistantText = "";
1059
+ firstResult.releaseRetained();
1060
+ if (isTranslatorBudgetExceededError(err)) {
1061
+ yield {
1062
+ type: "error",
1063
+ message: "upstream translation buffer exceeded the safe limit",
1064
+ status: 502,
1065
+ errorType: "upstream_error",
1066
+ code: "translation_buffer_limit",
1067
+ usage: firstResult.usage,
1068
+ };
1069
+ return;
1070
+ }
1071
+ yield {
1072
+ type: "error",
1073
+ message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)),
1074
+ status: err instanceof Error && err.name === "TimeoutError" ? 504 : 502,
1075
+ errorType: "upstream_error",
1076
+ retryable: !priorEmittedOutput,
1077
+ usage: firstResult.usage,
1078
+ };
1079
+ return;
1080
+ }
1081
+ // The factory has finished using the live first-attempt alias and has retained its own retry
1082
+ // serialization through the fetch boundary. The discarded parser collectors can now release
1083
+ // before the second attempt begins on the same turn budget.
1084
+ firstAssistantText = "";
1085
+ firstResult.assistantText = "";
1086
+ firstResult.releaseRetained();
1087
+ fallback.releaseRequestBody?.();
1088
+ if (!fallback.response.ok) {
1089
+ const payload = await fallback.response.text().catch(() => "");
1090
+ const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload);
1091
+ yield {
1092
+ type: "error",
1093
+ message: failure.message,
1094
+ status: failure.status,
1095
+ errorType: failure.errorType,
1096
+ code: failure.code,
1097
+ retryable: priorEmittedOutput ? false : failure.retryable,
1098
+ usage: firstResult.usage,
1099
+ };
1100
+ return;
1101
+ }
1102
+
1103
+ const secondResult = yield* parseKiroAttempt(
1104
+ fallback.response,
1105
+ budget,
1106
+ "text_fallback",
1107
+ modelId,
1108
+ fallback.inputTokens,
1109
+ contextWindowState,
1110
+ fallback.nameMap,
1111
+ fallback.conversationId,
1112
+ fallback.contextInputEstimate,
1113
+ // First attempt already flushed deferred progress to the client before this fallback.
1114
+ // A zero-output transport failure here must stay non-retryable to avoid duplicating that text.
1115
+ priorEmittedOutput,
1116
+ );
1117
+ try {
1118
+ if (!secondResult.terminal) {
1119
+ yield retryableKiroIncomplete(
1120
+ "empty_kiro_fallback",
1121
+ "Kiro's bounded completion retry ended without a terminal result",
1122
+ mergeKiroUsage(firstResult.usage, secondResult.usage, firstHadAssistantText)
1123
+ ?? { inputTokens, outputTokens: 0, estimated: true },
1124
+ secondResult.providerState ?? firstResult.providerState,
1125
+ !priorEmittedOutput,
1126
+ );
1127
+ return;
1128
+ }
1129
+ if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") {
1130
+ yield {
1131
+ ...secondResult.terminal,
1132
+ // Belt-and-suspenders: never advertise a replay-safe incomplete after flushed progress.
1133
+ ...(secondResult.terminal.type === "incomplete" && priorEmittedOutput
1134
+ ? { retryable: false as const }
1135
+ : {}),
1136
+ usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText),
1137
+ providerState: secondResult.terminal.providerState ?? firstResult.providerState,
1138
+ };
1139
+ return;
1140
+ }
1141
+ yield {
1142
+ ...secondResult.terminal,
1143
+ ...(secondResult.terminal.type === "error"
1144
+ ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText) }
1145
+ : {}),
1146
+ };
1147
+ } finally {
1148
+ secondResult.releaseRetained();
1149
+ }
1150
+ } finally {
1151
+ firstResult.releaseRetained();
1152
+ }
1153
+ }