@bitkyc08/opencodex 2.7.33 → 2.7.34

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 (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
@@ -5,11 +5,17 @@ import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
5
5
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
6
6
  import { modelRecordValue } from "../reasoning-effort";
7
7
  import { parseKiroEvent } from "./kiro-events";
8
- import { safeKiroErrorMessage, safeKiroHttpErrorMessage } from "./kiro-errors";
9
- import { appendFallbackText, toolCallFallbackText, toolResultFallbackText } from "./kiro-tool-fallback";
8
+ import {
9
+ classifyKiroEventError,
10
+ classifyKiroHttpError,
11
+ classifyKiroStreamError,
12
+ safeKiroErrorMessage,
13
+ safeKiroHttpErrorMessage,
14
+ type KiroErrorClassification,
15
+ } from "./kiro-errors";
10
16
  import { KiroThinkingParser } from "./kiro-thinking";
11
17
  import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation";
12
- import { fallbackToolUseId, fingerprint, invocationId, kiroToolName, mapModelId, normalizeToolId, osTag, stableConversationId } from "./kiro-wire";
18
+ import { createKiroToolNameRegistry, fallbackToolUseId, fingerprint, invocationId, isValidKiroConversationId, mapModelId, normalizeToolId, osTag, stableConversationId } from "./kiro-wire";
13
19
  import { namespacedToolName } from "../types";
14
20
  import type {
15
21
  AdapterEvent,
@@ -30,6 +36,14 @@ import { fetchKiroWithRetry } from "./kiro-retry";
30
36
  import { convertKiroToolContext } from "./kiro-tools";
31
37
  import { neutralizeIdentity } from "./identity";
32
38
  import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge";
39
+ import {
40
+ KIRO_COMPLETION_INSTRUCTIONS,
41
+ KIRO_COMPLETION_RETRY_MESSAGE,
42
+ KIRO_COMPLETION_TOOL_NAME,
43
+ KIRO_CONTINUATION_MESSAGE,
44
+ MAX_KIRO_INJECTED_INSTRUCTION_CHARS,
45
+ type KiroCompletionMode,
46
+ } from "./kiro-constants";
33
47
 
34
48
  const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
35
49
  const SDK_VERSION = "1.0.27";
@@ -90,17 +104,8 @@ function serializeForUsage(value: unknown): string {
90
104
  function currentTurnUsageMessages(messages: OcxMessage[]): OcxMessage[] {
91
105
  return messages.slice(messages.map(m => m.role).lastIndexOf("assistant") + 1).filter(m => m.role !== "assistant");
92
106
  }
93
- function currentTurnPayloadMessages(messages: OcxMessage[]): OcxMessage[] {
94
- const roles = messages.map(m => m.role);
95
- const lastAssistant = roles.lastIndexOf("assistant");
96
- const tail = messages.slice(lastAssistant + 1).filter(m => m.role !== "assistant");
97
- if (lastAssistant === -1 || !tail.some(m => m.role === "toolResult")) return tail;
98
- const priorAssistant = roles.slice(0, lastAssistant).lastIndexOf("assistant");
99
- return messages.slice(priorAssistant + 1);
100
- }
101
-
102
107
  function kiroPayloadMessages(parsed: OcxParsedRequest): OcxMessage[] {
103
- return parsed.previousResponseId ? currentTurnPayloadMessages(parsed.context.messages) : parsed.context.messages;
108
+ return parsed.context.messages;
104
109
  }
105
110
 
106
111
  function messageUsageText(msg: OcxMessage): string {
@@ -166,9 +171,21 @@ function configuredKiroContextWindow(provider: OcxProviderConfig, modelId: strin
166
171
  return typeof window === "number" && Number.isFinite(window) && window > 0 ? window : undefined;
167
172
  }
168
173
 
169
- function contextUsageTotalTokens(contextUsagePercentage: number | undefined, contextWindow: number | undefined): number | undefined {
170
- if (contextUsagePercentage === undefined || contextUsagePercentage <= 0 || !contextWindow) return undefined;
171
- return Math.max(0, Math.floor((contextUsagePercentage / 100) * contextWindow));
174
+ function kiroRuntimeEndpoint(provider: OcxProviderConfig, region: string): string {
175
+ const configured = new URL(provider.baseUrl);
176
+ if (
177
+ /^runtime\.[a-z]{2}(?:-[a-z]+)+-\d\.kiro\.dev$/i.test(configured.hostname)
178
+ && configured.pathname === "/"
179
+ ) {
180
+ return `https://runtime.${region}.kiro.dev/`;
181
+ }
182
+ return configured.toString();
183
+ }
184
+
185
+ export type KiroReasoningMode = "native" | "emulated";
186
+
187
+ export function kiroReasoningMode(modelId: string): KiroReasoningMode {
188
+ return normalizeKiroModelId(modelId) === "gpt-5.6-sol" ? "native" : "emulated";
172
189
  }
173
190
 
174
191
  function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined {
@@ -188,6 +205,7 @@ function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined {
188
205
  }
189
206
 
190
207
  function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): string {
208
+ if (kiroReasoningMode(parsed.modelId) !== "emulated") return content;
191
209
  const budget = kiroThinkingBudget(parsed);
192
210
  if (!budget) return content;
193
211
  const instruction = [
@@ -204,117 +222,197 @@ function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): stri
204
222
  ].join("\n");
205
223
  }
206
224
 
207
- export function buildKiroPayload(parsed: OcxParsedRequest, profileArn: string | undefined): { payload: Record<string, unknown>; nameMap: Map<string, string> } {
225
+ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
226
+ const choice = parsed.options.toolChoice;
227
+ if (choice !== undefined && choice !== "auto" && choice !== "none") {
228
+ throw new Error("Kiro supports only automatic tool choice or tool_choice:none");
229
+ }
230
+ if (parsed.options.parallelToolCalls === true) {
231
+ throw new Error("Kiro does not support parallel tool calls");
232
+ }
233
+ if (parsed.options.serviceTier !== undefined) {
234
+ throw new Error("Kiro does not support service tiers");
235
+ }
236
+ const raw = parsed._rawBody as Record<string, unknown> | undefined;
237
+ if (parsed._structuredOutput || raw?.text !== undefined) {
238
+ throw new Error("Kiro does not support Responses text controls or structured output");
239
+ }
240
+ }
241
+
242
+ type KiroTurn =
243
+ | { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
244
+ | { kind: "assistant"; content: string; toolUses: KiroToolUse[] };
245
+
246
+ function appendTurnText(target: string, next: string): string {
247
+ if (!next) return target;
248
+ return target ? `${target}\n\n${next}` : next;
249
+ }
250
+
251
+ function boundedInjectedInstruction(text: string, used: { value: number }): string | undefined {
252
+ const remaining = MAX_KIRO_INJECTED_INSTRUCTION_CHARS - used.value;
253
+ if (remaining <= 0 || !text) return undefined;
254
+ const result = text.length <= remaining ? text : text.slice(0, remaining);
255
+ used.value += result.length;
256
+ return result;
257
+ }
258
+
259
+ function kiroCompletionTool(): Record<string, unknown> {
260
+ return {
261
+ toolSpecification: {
262
+ name: KIRO_COMPLETION_TOOL_NAME,
263
+ description: "Finish the task and return the complete user-facing final answer. Call only when no more work or tool calls are needed.",
264
+ inputSchema: {
265
+ json: {
266
+ type: "object",
267
+ properties: {
268
+ answer: {
269
+ type: "string",
270
+ description: "The complete final answer to show the user.",
271
+ },
272
+ },
273
+ required: ["answer"],
274
+ },
275
+ },
276
+ },
277
+ };
278
+ }
279
+
280
+ export function buildKiroPayload(
281
+ parsed: OcxParsedRequest,
282
+ profileArn: string | undefined,
283
+ forcedCompletionMode?: KiroCompletionMode,
284
+ ): {
285
+ payload: Record<string, unknown>;
286
+ nameMap: Map<string, string>;
287
+ conversationId: string;
288
+ completionMode: KiroCompletionMode;
289
+ } {
290
+ validateKiroCapabilities(parsed);
208
291
  const modelId = mapModelId(parsed.modelId);
209
- const toolContext = convertKiroToolContext(parsed);
210
- const kiroTools = toolContext.tools;
292
+ const registry = createKiroToolNameRegistry();
293
+ const toolContext = convertKiroToolContext(parsed, registry);
294
+ const ordinaryTools = toolContext.tools;
295
+ const completionMode: KiroCompletionMode = forcedCompletionMode
296
+ ?? (ordinaryTools.length > 0 ? "required" : "disabled");
297
+ const kiroTools = completionMode === "disabled"
298
+ ? ordinaryTools
299
+ : [...ordinaryTools, kiroCompletionTool()];
211
300
  const nameMap = toolContext.nameMap;
212
301
  const systemParts: string[] = [];
302
+ const injectedChars = { value: 0 };
213
303
  // Neutralize Codex's GPT-5 identity line so a routed Kiro model never misreports as GPT-5/OpenAI
214
304
  // and the proxy identity never leaks upstream.
215
- if (!parsed.previousResponseId && parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n")));
216
- const toolCatalogNudge = !parsed.previousResponseId
217
- ? buildNonOpenAIToolCatalogNudgeFromNames(kiroToolWireNames(kiroTools))
218
- : undefined;
219
- if (toolCatalogNudge) systemParts.push(toolCatalogNudge);
220
- if (toolContext.systemAdditions.length > 0) systemParts.push(...toolContext.systemAdditions);
305
+ if (parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n")));
306
+ const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(kiroToolWireNames(kiroTools));
307
+ const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined;
308
+ if (boundedNudge) systemParts.push(boundedNudge);
309
+ if (completionMode !== "disabled") {
310
+ const boundedCompletion = boundedInjectedInstruction(KIRO_COMPLETION_INSTRUCTIONS, injectedChars);
311
+ if (boundedCompletion) systemParts.push(boundedCompletion);
312
+ }
221
313
  const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : "";
222
- const structuredToolIds = new Set<string>();
223
-
224
- const mkUser = (content: string, images?: KiroImage[]): KiroHistoryEntry => ({
225
- userInputMessage: {
226
- content,
227
- modelId,
228
- origin: "AI_EDITOR",
229
- ...(images && images.length > 0 ? { images } : {}),
230
- },
231
- });
232
- const history: KiroHistoryEntry[] = [];
233
- const fallbackEntries = new WeakSet<KiroHistoryEntry>();
234
- let pending: KiroToolResult[] = [];
235
- let pendingImages: KiroImage[] = [];
236
- let lastRole = "";
237
- const attachPending = (entry: KiroHistoryEntry): void => {
238
- if (pending.length === 0) return;
239
- const uim = entry.userInputMessage!;
240
- uim.userInputMessageContext = { ...(uim.userInputMessageContext ?? {}), toolResults: pending };
241
- if (pendingImages.length > 0) uim.images = [...(uim.images ?? []), ...pendingImages];
242
- pending = [];
243
- pendingImages = [];
314
+ const turns: KiroTurn[] = [];
315
+ const priorCalls = new Map<string, { wireName: string }>();
316
+ const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => {
317
+ const last = turns.at(-1);
318
+ if (last?.kind === "user") {
319
+ last.content = appendTurnText(last.content, content);
320
+ last.images.push(...images);
321
+ last.toolResults.push(...toolResults);
322
+ } else {
323
+ turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
324
+ }
244
325
  };
245
- const pushUserEntry = (entry: KiroHistoryEntry): void => {
246
- if (pending.length === 0 && lastRole === "user") {
247
- history.push({ assistantResponseMessage: { content: "(acknowledged)" } });
326
+ const pushAssistant = (content: string, toolUses: KiroToolUse[]): void => {
327
+ const last = turns.at(-1);
328
+ if (last?.kind === "assistant") {
329
+ last.content = appendTurnText(last.content, content);
330
+ last.toolUses.push(...toolUses);
331
+ } else {
332
+ turns.push({ kind: "assistant", content, toolUses: [...toolUses] });
248
333
  }
249
- attachPending(entry);
250
- history.push(entry);
251
- lastRole = "user";
252
334
  };
253
335
 
254
336
  for (const msg of kiroPayloadMessages(parsed)) {
255
337
  if (msg.role === "user" || msg.role === "developer") {
256
338
  const text = userContentText((msg as { content: string | OcxContentPart[] }).content);
257
339
  const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
258
- pushUserEntry(mkUser(text, images));
340
+ pushUser(text, images);
259
341
  } else if (msg.role === "assistant") {
260
- if (pending.length > 0) {
261
- const carrier = mkUser("(tool results)");
262
- pushUserEntry(carrier);
263
- }
264
342
  const aMsg = msg as OcxAssistantMessage;
265
- let text = (aMsg.content || [])
343
+ const text = (aMsg.content || [])
266
344
  .filter((b): b is OcxTextContent => b.type === "text")
267
345
  .map(b => b.text)
268
346
  .join("");
269
347
  const toolCalls = (aMsg.content || [])
270
348
  .filter((b): b is OcxToolCall => b.type === "toolCall");
271
- const toolUses: KiroToolUse[] = kiroTools.length > 0
272
- ? toolCalls.map(tc => {
273
- const toolUseId = normalizeToolId(tc.id);
274
- structuredToolIds.add(toolUseId);
275
- // Same deterministic normalization as the toolSpecification so the replayed assistant
276
- // toolUse name matches what Kiro was told the tool is called.
277
- return { name: kiroToolName(namespacedToolName(tc.namespace, tc.name)), input: (tc.arguments ?? {}) as Record<string, unknown>, toolUseId };
278
- })
279
- : [];
280
- if (kiroTools.length === 0) {
281
- for (const toolCall of toolCalls) text = appendFallbackText(text, toolCallFallbackText(toolCall));
349
+ const toolUses: KiroToolUse[] = toolCalls.map(tc => {
350
+ const toolUseId = normalizeToolId(tc.id);
351
+ if (!toolUseId) throw new Error("Kiro history contains a tool call with an empty id");
352
+ if (priorCalls.has(toolUseId)) throw new Error(`Kiro history contains duplicate tool call id ${JSON.stringify(tc.id)}`);
353
+ const wireName = namespacedToolName(tc.namespace, tc.name);
354
+ const name = registry.alias(wireName);
355
+ priorCalls.set(toolUseId, { wireName });
356
+ return { name, input: (tc.arguments ?? {}) as Record<string, unknown>, toolUseId };
357
+ });
358
+ if (!text && toolUses.length === 0) {
359
+ const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
360
+ if (hasReasoning) continue;
282
361
  }
283
- if (lastRole === "assistant") history.push(mkUser("(continue)"));
284
- const entry: KiroHistoryEntry = { assistantResponseMessage: { content: text } };
285
- if (toolUses.length > 0) entry.assistantResponseMessage!.toolUses = toolUses;
286
- history.push(entry);
287
- lastRole = "assistant";
362
+ pushAssistant(text, toolUses);
288
363
  } else if (msg.role === "toolResult") {
289
364
  const tr = msg as OcxToolResultMessage;
365
+ if (tr.containsEncryptedContent) {
366
+ throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
367
+ }
290
368
  const text = userContentText(tr.content);
291
369
  const images = extractKiroImages(tr.content);
292
370
  const toolUseId = normalizeToolId(tr.toolCallId);
293
- if (kiroTools.length > 0 && structuredToolIds.has(toolUseId)) {
294
- pending.push({
295
- content: [{ text: text || "(empty)" }],
296
- status: tr.isError ? "error" : "success",
297
- toolUseId,
298
- });
299
- pendingImages.push(...images);
300
- } else {
301
- if (pending.length > 0) pushUserEntry(mkUser("(tool results)"));
302
- const fallback = mkUser(toolResultFallbackText(tr), images);
303
- fallbackEntries.add(fallback);
304
- pushUserEntry(fallback);
371
+ if (!priorCalls.has(toolUseId)) {
372
+ throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
305
373
  }
374
+ pushUser("", images, [{
375
+ content: [{ text }],
376
+ status: tr.isError ? "error" : "success",
377
+ toolUseId,
378
+ }]);
306
379
  }
307
380
  }
308
381
 
309
- let currentEntry: KiroHistoryEntry;
310
- if (pending.length > 0) {
311
- currentEntry = mkUser("(tool results)");
312
- attachPending(currentEntry);
313
- } else if (history.length > 0 && history[history.length - 1].userInputMessage) {
314
- currentEntry = history.pop()!;
315
- } else {
316
- currentEntry = mkUser("(continue)");
382
+ // A reasoning-only first attempt has no Kiro-replayable assistant text. Preserve the turn
383
+ // boundary structurally so the adapter-generated retry is still a user turn after assistant
384
+ // history, without inventing prose that the model never said.
385
+ if (completionMode === "text_fallback" && turns.at(-1)?.kind !== "assistant") {
386
+ pushAssistant("", []);
387
+ }
388
+
389
+ if (turns.length === 0 || turns[0].kind === "assistant") {
390
+ turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
391
+ }
392
+ if (turns.at(-1)?.kind === "assistant") {
393
+ turns.push({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
317
394
  }
395
+
396
+ const currentTurn = turns.pop();
397
+ if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
398
+ const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
399
+ ? {
400
+ assistantResponseMessage: {
401
+ content: turn.content,
402
+ ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}),
403
+ },
404
+ }
405
+ : {
406
+ userInputMessage: {
407
+ content: turn.content,
408
+ modelId,
409
+ origin: "AI_EDITOR",
410
+ ...(turn.images.length > 0 ? { images: turn.images } : {}),
411
+ ...(turn.toolResults.length > 0 ? { userInputMessageContext: { toolResults: turn.toolResults } } : {}),
412
+ },
413
+ };
414
+ const history = turns.map(toEntry);
415
+ const currentEntry = toEntry(currentTurn);
318
416
  const currentUim = currentEntry.userInputMessage!;
319
417
 
320
418
  if (systemPrefix) {
@@ -325,168 +423,648 @@ export function buildKiroPayload(parsed: OcxParsedRequest, profileArn: string |
325
423
  if (kiroTools.length > 0) {
326
424
  currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
327
425
  }
328
- if (!fallbackEntries.has(currentEntry) && !currentUim.userInputMessageContext?.toolResults && currentUim.content !== "(continue)") {
426
+ if (completionMode === "text_fallback") {
427
+ currentUim.content = KIRO_COMPLETION_RETRY_MESSAGE;
428
+ } else if (!currentUim.userInputMessageContext?.toolResults && currentUim.content !== KIRO_CONTINUATION_MESSAGE) {
329
429
  currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
330
430
  }
331
431
 
432
+ const conversationId = stableConversationId(parsed);
332
433
  const payload: Record<string, unknown> = {
333
434
  conversationState: {
334
435
  chatTriggerType: "MANUAL",
335
- conversationId: stableConversationId(parsed),
436
+ conversationId,
336
437
  currentMessage: { userInputMessage: currentUim },
337
438
  ...(history.length > 0 ? { history } : {}),
338
439
  },
339
440
  };
441
+ const effort = parsed.options.reasoning;
442
+ if (kiroReasoningMode(parsed.modelId) === "native" && effort && effort !== "none") {
443
+ if (!["low", "medium", "high", "xhigh", "max"].includes(effort)) {
444
+ throw new Error(`Kiro gpt-5.6-sol does not support reasoning effort ${JSON.stringify(effort)}`);
445
+ }
446
+ payload.additionalModelRequestFields = { reasoning: { effort } };
447
+ }
340
448
  if (profileArn) payload.profileArn = profileArn;
341
- return { payload, nameMap };
449
+ return { payload, nameMap, conversationId, completionMode };
342
450
  }
343
451
 
344
452
  // Stream parsing (shared by parseStream + parseResponse)
345
453
  // CodeWhisperer GenerateAssistantResponse ALWAYS returns an AWS eventstream body (there is no
346
454
  // non-streaming mode), so both the streaming bridge and the non-streaming web-search sidecar loop
347
455
  // decode the same way — parseResponse just collects what parseStream yields.
348
- export async function* parseKiroStream(
456
+ interface KiroAttemptResult {
457
+ terminal?: AdapterEvent;
458
+ needsFallback?: boolean;
459
+ usage?: OcxUsage;
460
+ providerState?: { kiro: { conversationId: string } };
461
+ assistantText: string;
462
+ sawReasoning: boolean;
463
+ }
464
+
465
+ interface KiroFallbackAttempt {
466
+ response: Response;
467
+ inputTokens: number;
468
+ nameMap: Map<string, string>;
469
+ conversationId: string;
470
+ }
471
+
472
+ type KiroFallbackFactory = (
473
+ conversationId: string | undefined,
474
+ assistantText: string,
475
+ sawReasoning: boolean,
476
+ ) => Promise<KiroFallbackAttempt>;
477
+
478
+ function mergeKiroUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
479
+ if (!first) return second;
480
+ if (!second) return first;
481
+ const sumOptional = (key: keyof OcxUsage): number | undefined => {
482
+ const a = first[key];
483
+ const b = second[key];
484
+ return typeof a === "number" || typeof b === "number"
485
+ ? (typeof a === "number" ? a : 0) + (typeof b === "number" ? b : 0)
486
+ : undefined;
487
+ };
488
+ const totalTokens = typeof first.totalTokens === "number" && typeof second.totalTokens === "number"
489
+ ? first.totalTokens + second.totalTokens
490
+ : undefined;
491
+ return {
492
+ inputTokens: first.inputTokens + second.inputTokens,
493
+ outputTokens: first.outputTokens + second.outputTokens,
494
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
495
+ ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}),
496
+ ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}),
497
+ ...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}),
498
+ ...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}),
499
+ ...(first.estimated || second.estimated ? { estimated: true } : {}),
500
+ };
501
+ }
502
+
503
+ function retryableKiroIncomplete(
504
+ reason: string,
505
+ message: string,
506
+ usage: OcxUsage,
507
+ providerState: { kiro: { conversationId: string } } | undefined,
508
+ ): AdapterEvent {
509
+ return {
510
+ type: "incomplete",
511
+ reason,
512
+ message,
513
+ usage,
514
+ retryable: true,
515
+ endTurn: false,
516
+ ...(providerState ? { providerState } : {}),
517
+ };
518
+ }
519
+
520
+ function normalizedKiroAnswer(text: string): string {
521
+ return text.trim().replace(/\s+/g, " ");
522
+ }
523
+
524
+ function isRepeatedKiroAnswer(text: string, previous?: string): boolean {
525
+ return normalizedKiroAnswer(text) === normalizedKiroAnswer(previous ?? "");
526
+ }
527
+
528
+ async function* parseKiroAttempt(
349
529
  response: Response,
350
- modelId?: string,
351
- inputTokens = 0,
352
- contextWindow?: number,
353
- nameMap?: Map<string, string>,
354
- ): AsyncGenerator<AdapterEvent> {
530
+ mode: KiroCompletionMode,
531
+ modelId: string | undefined,
532
+ inputTokens: number,
533
+ contextWindow: number | undefined,
534
+ nameMap: Map<string, string> | undefined,
535
+ conversationId: string | undefined,
536
+ previousAssistantText?: string,
537
+ ): AsyncGenerator<AdapterEvent, KiroAttemptResult> {
538
+ const emptyResult = (): KiroAttemptResult => ({ assistantText: "", sawReasoning: false });
355
539
  if (!response.body) {
356
- yield { type: "error", message: "Kiro response has no body" };
357
- return;
540
+ return {
541
+ ...emptyResult(),
542
+ terminal: { type: "error", message: "Kiro response has no body", status: 502, errorType: "upstream_error" },
543
+ };
358
544
  }
359
- let open: { id: string; name: string; chunks: string[] } | null = null;
360
- // CW provides no usage; accumulate output chars and emit a heuristic estimate on done so Codex's
361
- // usage display + auto-compact engage (see src/lib/token-estimate.ts).
545
+
546
+ let open: { id: string; name: string; chunks: string[]; completion: boolean } | null = null;
362
547
  let outputChars = "";
363
548
  let contextUsagePercentage: number | undefined;
549
+ let returnedConversationId = conversationId;
550
+ let assistantText = "";
551
+ let sawText = false;
552
+ let sawReasoning = false;
553
+ let sawRealTool = false;
554
+ let completionAnswer: string | undefined;
555
+ let completionCalls = 0;
556
+ let authoritativeUsage: OcxUsage | undefined;
557
+ const fallbackEvents: AdapterEvent[] = [];
364
558
  const thinking = new KiroThinkingParser();
365
- const trackContent = (event: AdapterEvent): void => {
366
- if ("text" in event) outputChars += event.text;
559
+
560
+ const providerState = (): { kiro: { conversationId: string } } | undefined =>
561
+ returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined;
562
+
563
+ const usage = (): OcxUsage => authoritativeUsage ?? ({
564
+ inputTokens,
565
+ outputTokens: estimateTokens(outputChars, modelId),
566
+ estimated: true,
567
+ });
568
+
569
+ const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => ({
570
+ type: "error",
571
+ message: failure.message,
572
+ status: failure.status,
573
+ errorType: failure.errorType,
574
+ code: failure.code,
575
+ retryable: failure.retryable,
576
+ usage: usage(),
577
+ });
578
+
579
+ const protocolTerminal = (message: string, malformedCompletion = false): AdapterEvent => {
580
+ if (mode === "text_fallback" && malformedCompletion) {
581
+ return retryableKiroIncomplete(
582
+ "malformed_kiro_completion",
583
+ message,
584
+ usage(),
585
+ providerState(),
586
+ );
587
+ }
588
+ return {
589
+ type: "error",
590
+ message,
591
+ status: 502,
592
+ errorType: "upstream_error",
593
+ code: malformedCompletion ? "invalid_kiro_completion" : "kiro_stream_protocol_error",
594
+ retryable: false,
595
+ usage: usage(),
596
+ };
597
+ };
598
+
599
+ const classifyTool = (
600
+ tool: { id: string; name: string; chunks: string[]; completion: boolean },
601
+ ): AdapterEvent | undefined => {
602
+ if (tool.name !== KIRO_COMPLETION_TOOL_NAME) {
603
+ tool.completion = false;
604
+ return completionAnswer !== undefined || completionCalls > 0
605
+ ? protocolTerminal("Kiro returned a real tool call alongside a private final answer")
606
+ : undefined;
607
+ }
608
+ if (mode === "disabled") {
609
+ return protocolTerminal("Kiro returned the reserved private final-answer tool while explicit completion was disabled");
610
+ }
611
+ tool.completion = true;
612
+ if (completionAnswer !== undefined || completionCalls > 0) {
613
+ return protocolTerminal("Kiro returned more than one private final-answer tool call", true);
614
+ }
615
+ if (sawRealTool) {
616
+ return protocolTerminal("Kiro returned a private final answer alongside a real tool call");
617
+ }
618
+ return undefined;
619
+ };
620
+
621
+ const beginTool = (
622
+ id: string,
623
+ name: string,
624
+ ): { tool?: { id: string; name: string; chunks: string[]; completion: boolean }; terminal?: AdapterEvent } => {
625
+ const next = { id, name, chunks: [], completion: false };
626
+ const terminal = classifyTool(next);
627
+ return terminal ? { terminal } : { tool: next };
367
628
  };
368
- function* flushTool(): Generator<AdapterEvent> {
369
- if (!open) return;
629
+
630
+ const stage = (event: AdapterEvent): AdapterEvent[] => {
631
+ if (event.type === "text_delta") {
632
+ assistantText += event.text;
633
+ if (event.text.trim()) sawText = true;
634
+ outputChars += event.text;
635
+ const phased = mode === "disabled"
636
+ ? event
637
+ : { ...event, phase: "commentary" as const };
638
+ if (mode === "text_fallback") {
639
+ fallbackEvents.push(phased);
640
+ return [];
641
+ }
642
+ return [phased];
643
+ }
644
+ if (event.type === "reasoning_raw_delta" || event.type === "thinking_delta") {
645
+ const text = event.type === "reasoning_raw_delta" ? event.text : event.thinking;
646
+ if (text.trim()) sawReasoning = true;
647
+ outputChars += text;
648
+ }
649
+ if (mode === "text_fallback" && event.type !== "heartbeat") {
650
+ fallbackEvents.push(event);
651
+ return [];
652
+ }
653
+ return [event];
654
+ };
655
+
656
+ const parseCompletion = (chunks: string[]): string | Error => {
657
+ const raw = chunks.join("").trim();
658
+ let value: unknown;
659
+ try {
660
+ value = JSON.parse(raw || "{}");
661
+ } catch {
662
+ return new Error("Kiro returned invalid JSON for the private final-answer tool");
663
+ }
664
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
665
+ return new Error("Kiro returned a non-object value for the private final-answer tool");
666
+ }
667
+ const answer = (value as { answer?: unknown }).answer;
668
+ if (typeof answer !== "string" || !answer.trim()) {
669
+ return new Error("Kiro returned an empty final answer");
670
+ }
671
+ return answer;
672
+ };
673
+
674
+ const flushOpen = (): { events: AdapterEvent[]; terminal?: AdapterEvent } => {
675
+ if (!open) return { events: [] };
370
676
  const tool = open;
371
677
  open = null;
372
- // Restore the original wire name if it was normalized for Kiro (spaces/length), so the bridge's
373
- // toolNsMap (keyed by the original wire name) can route the call back to its MCP namespace.
678
+ const input = tool.chunks.join("");
679
+ if (!isCompleteKiroToolInput(input)) {
680
+ return { events: [], terminal: protocolTerminal(kiroTruncationErrorMessage("incomplete tool input JSON"), tool.completion) };
681
+ }
682
+ if (tool.completion) {
683
+ completionCalls++;
684
+ if (completionCalls > 1) {
685
+ return { events: [], terminal: protocolTerminal("Kiro returned more than one private final-answer tool call", true) };
686
+ }
687
+ if (sawRealTool) {
688
+ return { events: [], terminal: protocolTerminal("Kiro returned a private final answer alongside a real tool call") };
689
+ }
690
+ const answer = parseCompletion(tool.chunks);
691
+ if (answer instanceof Error) return { events: [], terminal: protocolTerminal(answer.message, true) };
692
+ completionAnswer = answer;
693
+ return { events: [] };
694
+ }
695
+ if (completionAnswer !== undefined || completionCalls > 0) {
696
+ return { events: [], terminal: protocolTerminal("Kiro returned a real tool call alongside a private final answer") };
697
+ }
698
+ sawRealTool = true;
374
699
  const restored = nameMap?.get(tool.name) ?? tool.name;
375
- yield { type: "tool_call_start", id: tool.id, name: restored };
376
- for (const chunk of tool.chunks) if (chunk) yield { type: "tool_call_delta", arguments: chunk };
377
- yield { type: "tool_call_end" };
378
- }
700
+ return {
701
+ events: [
702
+ { type: "tool_call_start", id: tool.id, name: restored },
703
+ ...tool.chunks.filter(Boolean).map(argumentsChunk => ({ type: "tool_call_delta", arguments: argumentsChunk }) as AdapterEvent),
704
+ { type: "tool_call_end" },
705
+ ],
706
+ };
707
+ };
708
+
379
709
  try {
380
710
  for await (const msg of decodeEventStream(response.body)) {
381
711
  const mt = msg.headers[":message-type"];
382
712
  if (mt === "exception" || mt === "error") {
383
- // Terminal: surface the upstream error and never emit a trailing success-shaped `done`.
384
713
  open = null;
385
- yield { type: "error", message: safeKiroErrorMessage(msg.headers, new TextDecoder().decode(msg.payload)) };
386
- return;
714
+ return {
715
+ assistantText,
716
+ sawReasoning,
717
+ terminal: classifiedTerminal(classifyKiroStreamError(msg.headers, new TextDecoder().decode(msg.payload))),
718
+ };
719
+ }
720
+ if (mt !== "event") {
721
+ open = null;
722
+ return {
723
+ assistantText,
724
+ sawReasoning,
725
+ terminal: protocolTerminal(`Kiro response protocol error: unsupported Smithy message type ${JSON.stringify(mt ?? "missing")}`),
726
+ };
727
+ }
728
+ const eventType = msg.headers[":event-type"];
729
+ if (!eventType) {
730
+ open = null;
731
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: event is missing :event-type") };
387
732
  }
388
- if (mt && mt !== "event") continue;
389
- const ev = parseKiroEvent(msg.payload);
733
+ const ev = parseKiroEvent(eventType, msg.payload);
390
734
  if (!ev) continue;
391
735
  switch (ev.type) {
392
- case "usage":
393
- break;
394
- case "context_usage":
736
+ case "metadata":
737
+ if (ev.usage) authoritativeUsage = ev.usage;
395
738
  if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) {
396
739
  contextUsagePercentage = ev.contextUsagePercentage;
397
740
  }
398
741
  break;
742
+ case "message_metadata":
743
+ if (isValidKiroConversationId(ev.conversationId)) returnedConversationId = ev.conversationId;
744
+ break;
399
745
  case "content":
400
746
  if (open) {
401
747
  open = null;
402
- yield { type: "error", message: kiroTruncationErrorMessage("content arrived before tool stop") };
403
- return;
748
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("content arrived before tool stop")) };
404
749
  }
405
750
  if (ev.data) {
406
751
  for (const contentEvent of thinking.feed(ev.data)) {
407
- trackContent(contentEvent);
408
- yield contentEvent;
752
+ for (const staged of stage(contentEvent)) yield staged;
409
753
  }
410
754
  }
411
755
  break;
412
- case "tool_start": {
756
+ case "reasoning":
413
757
  for (const contentEvent of thinking.flush()) {
414
- trackContent(contentEvent);
415
- yield contentEvent;
758
+ for (const staged of stage(contentEvent)) yield staged;
416
759
  }
417
- const id = ev.toolUseId || fallbackToolUseId();
418
- const name = ev.name || "unknown";
419
- if (open) {
420
- if (open.id !== id || open.name !== name) {
421
- open = null;
422
- yield { type: "error", message: kiroTruncationErrorMessage("new tool started before previous tool stop") };
423
- return;
424
- }
425
- } else {
426
- open = { id, name, chunks: [] };
760
+ if (ev.data) {
761
+ for (const staged of stage({ type: "reasoning_raw_delta", text: ev.data })) yield staged;
427
762
  }
428
- yield { type: "heartbeat" };
429
763
  break;
430
- }
431
- case "tool_input": {
764
+ case "tool": {
432
765
  for (const contentEvent of thinking.flush()) {
433
- trackContent(contentEvent);
434
- yield contentEvent;
766
+ for (const staged of stage(contentEvent)) yield staged;
435
767
  }
436
768
  if (!open) {
437
- open = { id: ev.toolUseId || fallbackToolUseId(), name: ev.name || "unknown", chunks: [] };
769
+ if (ev.stop === true) {
770
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: tool stop received without an open tool call") };
771
+ }
772
+ if (!ev.toolUseId || !ev.name) {
773
+ return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: new tool event is missing toolUseId or name") };
774
+ }
775
+ const started = beginTool(ev.toolUseId, ev.name);
776
+ if (started.terminal) return { assistantText, sawReasoning, terminal: started.terminal };
777
+ open = started.tool!;
778
+ } else if (
779
+ (ev.toolUseId && ev.toolUseId !== open.id)
780
+ || (ev.name && open.name !== "unknown" && ev.name !== open.name)
781
+ ) {
782
+ open = null;
783
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("tool input changed identity before stop")) };
784
+ }
785
+ if (open && open.name === "unknown" && ev.name) {
786
+ open.name = ev.name;
787
+ const terminal = classifyTool(open);
788
+ if (terminal) {
789
+ open = null;
790
+ return { assistantText, sawReasoning, terminal };
791
+ }
438
792
  }
439
- if (open && ev.input) {
440
- if (open.name === "unknown" && ev.name) open.name = ev.name;
793
+ if (open && ev.input !== undefined) {
441
794
  open.chunks.push(ev.input);
442
795
  outputChars += ev.input;
443
796
  }
444
- yield { type: "heartbeat" };
445
- break;
446
- }
447
- case "tool_stop": {
448
- if (!open) {
449
- yield { type: "error", message: "Kiro response protocol error: tool stop received without an open tool call" };
450
- return;
451
- }
452
- const input = open.chunks.join("");
453
- if (!isCompleteKiroToolInput(input)) {
454
- open = null;
455
- yield { type: "error", message: kiroTruncationErrorMessage("incomplete tool input JSON") };
456
- return;
797
+ if (ev.stop === true) {
798
+ const flushed = flushOpen();
799
+ if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal };
800
+ for (const event of flushed.events) {
801
+ for (const staged of stage(event)) yield staged;
802
+ }
803
+ } else {
804
+ yield { type: "heartbeat" };
457
805
  }
458
- yield* flushTool();
459
806
  break;
460
807
  }
808
+ case "invalid_state":
809
+ open = null;
810
+ return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(undefined, ev.message ?? "Kiro entered an invalid state")) };
811
+ case "error":
812
+ open = null;
813
+ return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(ev.reason, ev.message)) };
461
814
  case "truncation":
462
815
  open = null;
463
- yield { type: "error", message: kiroTruncationErrorMessage(ev.data) };
464
- return;
816
+ return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage(ev.data)) };
465
817
  }
466
818
  }
819
+
467
820
  for (const contentEvent of thinking.flush()) {
468
- trackContent(contentEvent);
469
- yield contentEvent;
821
+ for (const staged of stage(contentEvent)) yield staged;
470
822
  }
471
823
  if (open) {
472
824
  const input = open.chunks.join("");
473
825
  if (!isCompleteKiroToolInput(input)) {
826
+ const privateTool = open.completion;
474
827
  open = null;
475
- yield { type: "error", message: kiroTruncationErrorMessage("stream ended before tool stop") };
476
- return;
828
+ return {
829
+ assistantText,
830
+ sawReasoning,
831
+ terminal: protocolTerminal(kiroTruncationErrorMessage("stream ended before tool stop"), privateTool),
832
+ };
833
+ }
834
+ const flushed = flushOpen();
835
+ if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal };
836
+ for (const event of flushed.events) {
837
+ for (const staged of stage(event)) yield staged;
477
838
  }
478
- yield* flushTool();
479
839
  }
480
- const outputTokens = estimateTokens(outputChars, modelId);
481
- const usage: OcxUsage = { inputTokens, outputTokens, estimated: true };
482
- const totalTokens = contextUsageTotalTokens(contextUsagePercentage, contextWindow);
483
- if (totalTokens !== undefined) usage.totalTokens = totalTokens;
484
- yield { type: "done", usage };
840
+
841
+ const finalUsage = usage();
842
+ const finalProviderState = providerState();
843
+ if (contextUsagePercentage !== undefined) {
844
+ debugProviderDiagnostic("kiro", "context_usage", {
845
+ contextUsagePercentage,
846
+ ...(contextWindow ? { configuredContextWindow: contextWindow } : {}),
847
+ });
848
+ }
849
+
850
+ if (mode === "text_fallback") {
851
+ if (completionAnswer !== undefined) {
852
+ for (const event of fallbackEvents) yield event;
853
+ if (!isRepeatedKiroAnswer(completionAnswer, previousAssistantText)) {
854
+ yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
855
+ }
856
+ return {
857
+ assistantText,
858
+ sawReasoning,
859
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
860
+ };
861
+ }
862
+ if (sawRealTool) {
863
+ for (const event of fallbackEvents) yield event;
864
+ return {
865
+ assistantText,
866
+ sawReasoning,
867
+ terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
868
+ };
869
+ }
870
+ if (sawText) {
871
+ const repeated = isRepeatedKiroAnswer(assistantText, previousAssistantText);
872
+ for (const event of fallbackEvents) {
873
+ if (event.type !== "text_delta") yield event;
874
+ else if (!repeated) yield { ...event, phase: "final_answer" };
875
+ }
876
+ return {
877
+ assistantText,
878
+ sawReasoning,
879
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
880
+ };
881
+ }
882
+ for (const event of fallbackEvents) yield event;
883
+ return {
884
+ assistantText,
885
+ sawReasoning,
886
+ terminal: retryableKiroIncomplete(
887
+ sawReasoning ? "reasoning_only_kiro_fallback" : "empty_kiro_fallback",
888
+ sawReasoning
889
+ ? "Kiro produced reasoning but no final answer on its bounded completion retry"
890
+ : "Kiro produced no final answer on its bounded completion retry",
891
+ finalUsage,
892
+ finalProviderState,
893
+ ),
894
+ };
895
+ }
896
+
897
+ if (completionAnswer !== undefined) {
898
+ yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
899
+ return {
900
+ assistantText,
901
+ sawReasoning,
902
+ terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
903
+ };
904
+ }
905
+ if (sawRealTool) {
906
+ return {
907
+ assistantText,
908
+ sawReasoning,
909
+ terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
910
+ };
911
+ }
912
+ if (mode === "required" && (sawText || sawReasoning)) {
913
+ return { assistantText, sawReasoning, needsFallback: true, usage: finalUsage, providerState: finalProviderState };
914
+ }
915
+ if (!sawText && !sawReasoning) {
916
+ return {
917
+ assistantText,
918
+ sawReasoning,
919
+ terminal: retryableKiroIncomplete(
920
+ "empty_kiro_stream",
921
+ "Kiro returned a successful but empty response stream",
922
+ finalUsage,
923
+ finalProviderState,
924
+ ),
925
+ };
926
+ }
927
+ return {
928
+ assistantText,
929
+ sawReasoning,
930
+ terminal: {
931
+ type: "done",
932
+ usage: finalUsage,
933
+ endTurn: mode === "disabled" ? sawText : false,
934
+ ...(finalProviderState ? { providerState: finalProviderState } : {}),
935
+ },
936
+ };
485
937
  } catch (err) {
486
- yield { type: "error", message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)) };
938
+ return {
939
+ assistantText,
940
+ sawReasoning,
941
+ terminal: {
942
+ type: "error",
943
+ message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)),
944
+ status: 502,
945
+ errorType: "server_error",
946
+ code: "kiro_stream_protocol_error",
947
+ retryable: false,
948
+ usage: usage(),
949
+ },
950
+ };
487
951
  }
488
952
  }
489
953
 
954
+ export async function* parseKiroStream(
955
+ response: Response,
956
+ modelId?: string,
957
+ inputTokens = 0,
958
+ contextWindow?: number,
959
+ nameMap?: Map<string, string>,
960
+ conversationId?: string,
961
+ completionMode: KiroCompletionMode = "disabled",
962
+ fallbackFactory?: KiroFallbackFactory,
963
+ ): AsyncGenerator<AdapterEvent> {
964
+ const first = parseKiroAttempt(
965
+ response,
966
+ completionMode,
967
+ modelId,
968
+ inputTokens,
969
+ contextWindow,
970
+ nameMap,
971
+ conversationId,
972
+ );
973
+ let firstNext = await first.next();
974
+ while (!firstNext.done) {
975
+ yield firstNext.value;
976
+ firstNext = await first.next();
977
+ }
978
+ const firstResult = firstNext.value;
979
+ if (!firstResult.needsFallback) {
980
+ if (firstResult.terminal) yield firstResult.terminal;
981
+ return;
982
+ }
983
+ if (!fallbackFactory) {
984
+ yield retryableKiroIncomplete(
985
+ "uncompleted_kiro_response",
986
+ "Kiro produced progress without an explicit final answer and no bounded retry transport was available",
987
+ firstResult.usage ?? { inputTokens, outputTokens: 0, estimated: true },
988
+ firstResult.providerState,
989
+ );
990
+ return;
991
+ }
992
+
993
+ yield { type: "heartbeat" };
994
+ let fallback: KiroFallbackAttempt;
995
+ try {
996
+ fallback = await fallbackFactory(
997
+ firstResult.providerState?.kiro.conversationId ?? conversationId,
998
+ firstResult.assistantText,
999
+ firstResult.sawReasoning,
1000
+ );
1001
+ } catch (err) {
1002
+ yield {
1003
+ type: "error",
1004
+ message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)),
1005
+ status: err instanceof Error && err.name === "TimeoutError" ? 504 : 502,
1006
+ errorType: "upstream_error",
1007
+ retryable: true,
1008
+ usage: firstResult.usage,
1009
+ };
1010
+ return;
1011
+ }
1012
+ if (!fallback.response.ok) {
1013
+ const payload = await fallback.response.text().catch(() => "");
1014
+ const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload);
1015
+ yield {
1016
+ type: "error",
1017
+ message: failure.message,
1018
+ status: failure.status,
1019
+ errorType: failure.errorType,
1020
+ code: failure.code,
1021
+ retryable: failure.retryable,
1022
+ usage: firstResult.usage,
1023
+ };
1024
+ return;
1025
+ }
1026
+
1027
+ const second = parseKiroAttempt(
1028
+ fallback.response,
1029
+ "text_fallback",
1030
+ modelId,
1031
+ fallback.inputTokens,
1032
+ contextWindow,
1033
+ fallback.nameMap,
1034
+ fallback.conversationId,
1035
+ firstResult.assistantText,
1036
+ );
1037
+ let secondNext = await second.next();
1038
+ while (!secondNext.done) {
1039
+ yield secondNext.value;
1040
+ secondNext = await second.next();
1041
+ }
1042
+ const secondResult = secondNext.value;
1043
+ if (!secondResult.terminal) {
1044
+ yield retryableKiroIncomplete(
1045
+ "empty_kiro_fallback",
1046
+ "Kiro's bounded completion retry ended without a terminal result",
1047
+ mergeKiroUsage(firstResult.usage, secondResult.usage) ?? { inputTokens, outputTokens: 0, estimated: true },
1048
+ secondResult.providerState ?? firstResult.providerState,
1049
+ );
1050
+ return;
1051
+ }
1052
+ if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") {
1053
+ yield {
1054
+ ...secondResult.terminal,
1055
+ usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage),
1056
+ providerState: secondResult.terminal.providerState ?? firstResult.providerState,
1057
+ };
1058
+ return;
1059
+ }
1060
+ yield {
1061
+ ...secondResult.terminal,
1062
+ ...(secondResult.terminal.type === "error"
1063
+ ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage) }
1064
+ : {}),
1065
+ };
1066
+ }
1067
+
490
1068
  // Adapter
491
1069
  export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter {
492
1070
  // Per-request closure (resolveAdapter builds a fresh adapter per request — server.ts:440 — so this
@@ -495,64 +1073,140 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
495
1073
  let modelId: string | undefined;
496
1074
  let contextWindow: number | undefined;
497
1075
  let toolNameMap: Map<string, string> | undefined;
498
- return {
499
- name: "kiro",
500
- async buildRequest(parsed: OcxParsedRequest) {
501
- if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
502
- throw new Error("kiro token missing — run ocx login kiro");
503
- }
504
- const region = resolveKiroApiRegion();
505
- const profileArn = resolveKiroProfileArn();
506
- const fp = fingerprint().slice(0, 64);
507
- const headers: Record<string, string> = {
508
- authorization: `Bearer ${provider.apiKey}`,
509
- "content-type": "application/x-amz-json-1.0",
510
- accept: "application/vnd.amazon.eventstream",
511
- "x-amz-target": AMZ_TARGET,
512
- "user-agent": `aws-sdk-js/${SDK_VERSION} ua/2.1 os/${osTag()} lang/js md/nodejs#${NODE_VERSION} api/codewhispererstreaming#${SDK_VERSION} m/E KiroIDE-${KIRO_IDE_VERSION}-${fp}`,
513
- "x-amz-user-agent": `aws-sdk-js/${SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${fp}`,
514
- "x-amzn-codewhisperer-optout": "true",
515
- "x-amzn-kiro-agent-mode": "vibe",
516
- "amz-sdk-invocation-id": invocationId(),
517
- };
518
- if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn;
519
- // CodeWhisperer GenerateAssistantResponse has no reasoning_effort field. Match kiro-gateway's
520
- // fake-reasoning contract by injecting effort-derived thinking tags into only the current user turn.
521
- const built = buildKiroPayload(parsed, profileArn);
522
- toolNameMap = built.nameMap;
523
- // Generous image pipeline (devlog/260714_image_normalization_pipeline/050):
524
- // tier-normalize + cap images before serialization so bodyBytes below reflects
525
- // the normalized size.
526
- await normalizeKiroImages(built.payload);
527
- const body = JSON.stringify(built.payload);
528
- debugProviderDiagnostic("kiro", "request", {
529
- region,
530
- requestedModel: parsed.modelId,
531
- bodyBytes: new TextEncoder().encode(body).length,
532
- messageCount: kiroPayloadMessages(parsed).length,
533
- toolCount: parsed.context.tools?.length ?? 0,
534
- hasProfileArn: Boolean(profileArn),
535
- hasPreviousResponseId: Boolean(parsed.previousResponseId),
536
- });
537
- // CW returns no usage. Codex adds each response's usage into its session total; report only the
538
- // current-turn input delta so old history is not repeatedly added to Codex's visible token usage.
539
- modelId = parsed.modelId;
540
- contextWindow = configuredKiroContextWindow(provider, parsed.modelId);
541
- inputTokens = estimateKiroInputTokens(parsed);
542
- return {
543
- url: `https://runtime.${region}.kiro.dev/`,
1076
+ let conversationId: string | undefined;
1077
+ let completionMode: KiroCompletionMode = "disabled";
1078
+ let requestSnapshot: OcxParsedRequest | undefined;
1079
+ let requestAbortSignal: AbortSignal | undefined;
1080
+
1081
+ const build = async (
1082
+ parsed: OcxParsedRequest,
1083
+ forcedCompletionMode?: KiroCompletionMode,
1084
+ ): Promise<{
1085
+ request: AdapterRequest;
1086
+ nameMap: Map<string, string>;
1087
+ conversationId: string;
1088
+ completionMode: KiroCompletionMode;
1089
+ inputTokens: number;
1090
+ }> => {
1091
+ if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
1092
+ throw new Error("kiro token missing — run ocx login kiro");
1093
+ }
1094
+ const region = resolveKiroApiRegion();
1095
+ const profileArn = resolveKiroProfileArn();
1096
+ const fp = fingerprint().slice(0, 64);
1097
+ const headers: Record<string, string> = {
1098
+ authorization: `Bearer ${provider.apiKey}`,
1099
+ "content-type": "application/x-amz-json-1.0",
1100
+ accept: "application/vnd.amazon.eventstream",
1101
+ "x-amz-target": AMZ_TARGET,
1102
+ "user-agent": `aws-sdk-js/${SDK_VERSION} ua/2.1 os/${osTag()} lang/js md/nodejs#${NODE_VERSION} api/codewhispererstreaming#${SDK_VERSION} m/E KiroIDE-${KIRO_IDE_VERSION}-${fp}`,
1103
+ "x-amz-user-agent": `aws-sdk-js/${SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${fp}`,
1104
+ "x-amzn-codewhisperer-optout": "true",
1105
+ "x-amzn-kiro-agent-mode": "vibe",
1106
+ "amz-sdk-invocation-id": invocationId(),
1107
+ };
1108
+ if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn;
1109
+ const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode);
1110
+ await normalizeKiroImages(built.payload);
1111
+ const body = JSON.stringify(built.payload);
1112
+ debugProviderDiagnostic("kiro", "request", {
1113
+ region,
1114
+ requestedModel: parsed.modelId,
1115
+ completionMode: built.completionMode,
1116
+ bodyBytes: new TextEncoder().encode(body).length,
1117
+ messageCount: kiroPayloadMessages(parsed).length,
1118
+ toolCount: parsed.context.tools?.length ?? 0,
1119
+ hasProfileArn: Boolean(profileArn),
1120
+ hasPreviousResponseId: Boolean(parsed.previousResponseId),
1121
+ });
1122
+ return {
1123
+ request: {
1124
+ url: kiroRuntimeEndpoint(provider, region),
544
1125
  method: "POST",
545
1126
  headers,
546
1127
  body,
547
1128
  usageLog: { inputTokens: estimateKiroLogInputTokens(parsed), estimated: true },
548
- };
1129
+ },
1130
+ nameMap: built.nameMap,
1131
+ conversationId: built.conversationId,
1132
+ completionMode: built.completionMode,
1133
+ inputTokens: estimateKiroInputTokens(parsed),
1134
+ };
1135
+ };
1136
+
1137
+ const fallbackFactory: KiroFallbackFactory = async (
1138
+ returnedConversationId,
1139
+ assistantText,
1140
+ sawReasoning,
1141
+ ) => {
1142
+ if (!requestSnapshot) throw new Error("Kiro completion retry lost its request state");
1143
+ if (requestAbortSignal?.aborted) {
1144
+ throw requestAbortSignal.reason instanceof Error
1145
+ ? requestAbortSignal.reason
1146
+ : new DOMException("Kiro request was cancelled", "AbortError");
1147
+ }
1148
+ const retryParsed = structuredClone(requestSnapshot);
1149
+ retryParsed._providerContinuation = {
1150
+ ...(retryParsed._providerContinuation ?? {}),
1151
+ ...(returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : {}),
1152
+ };
1153
+ retryParsed.context.messages.push({
1154
+ role: "assistant",
1155
+ content: [
1156
+ ...(sawReasoning ? [{ type: "thinking" as const, thinking: "" }] : []),
1157
+ ...(assistantText ? [{ type: "text" as const, text: assistantText }] : []),
1158
+ ],
1159
+ phase: "commentary",
1160
+ model: retryParsed.modelId,
1161
+ timestamp: Date.now(),
1162
+ });
1163
+ const retry = await build(retryParsed, "text_fallback");
1164
+ const response = await fetchKiroWithRetry(retry.request, {
1165
+ abortSignal: requestAbortSignal,
1166
+ returnRawErrors: true,
1167
+ stream: true,
1168
+ });
1169
+ return {
1170
+ response,
1171
+ inputTokens: retry.inputTokens,
1172
+ nameMap: retry.nameMap,
1173
+ conversationId: retry.conversationId,
1174
+ };
1175
+ };
1176
+
1177
+ return {
1178
+ name: "kiro",
1179
+ async buildRequest(parsed: OcxParsedRequest, incoming) {
1180
+ const built = await build(parsed);
1181
+ modelId = parsed.modelId;
1182
+ contextWindow = configuredKiroContextWindow(provider, parsed.modelId);
1183
+ inputTokens = built.inputTokens;
1184
+ toolNameMap = built.nameMap;
1185
+ conversationId = built.conversationId;
1186
+ completionMode = built.completionMode;
1187
+ requestSnapshot = structuredClone(parsed);
1188
+ requestAbortSignal = incoming?.abortSignal;
1189
+ return built.request;
549
1190
  },
550
1191
 
551
1192
  parseStream(response: Response): AsyncGenerator<AdapterEvent> {
552
- return parseKiroStream(response, modelId, inputTokens, contextWindow, toolNameMap);
1193
+ return parseKiroStream(
1194
+ response,
1195
+ modelId,
1196
+ inputTokens,
1197
+ contextWindow,
1198
+ toolNameMap,
1199
+ conversationId,
1200
+ completionMode,
1201
+ completionMode === "required" ? fallbackFactory : undefined,
1202
+ );
553
1203
  },
554
1204
 
555
1205
  fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> {
1206
+ // The normal Responses path supplies cancellation at fetch time rather than build time.
1207
+ // Keep it for the adapter-owned bounded continuation so cancelling the client turn aborts
1208
+ // both the first Kiro request and its one allowed completion retry.
1209
+ if (ctx?.abortSignal) requestAbortSignal = ctx.abortSignal;
556
1210
  return fetchKiroWithRetry(request, ctx);
557
1211
  },
558
1212
 
@@ -566,7 +1220,16 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
566
1220
  // tool failed with "web-search sidecar requires a non-streaming adapter" (kiro-only).
567
1221
  async parseResponse(response: Response): Promise<AdapterEvent[]> {
568
1222
  const events: AdapterEvent[] = [];
569
- for await (const e of parseKiroStream(response, modelId, inputTokens, contextWindow, toolNameMap)) events.push(e);
1223
+ for await (const e of parseKiroStream(
1224
+ response,
1225
+ modelId,
1226
+ inputTokens,
1227
+ contextWindow,
1228
+ toolNameMap,
1229
+ conversationId,
1230
+ completionMode,
1231
+ completionMode === "required" ? fallbackFactory : undefined,
1232
+ )) events.push(e);
570
1233
  return events;
571
1234
  },
572
1235
  };