@bitkyc08/opencodex 2.7.41 → 2.7.42

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 (80) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-Bl_VBGoI.js +65 -0
  3. package/gui/dist/assets/index-DfVGuN88.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +6 -0
  7. package/src/adapters/kiro-constants.ts +6 -2
  8. package/src/adapters/kiro-retry.ts +175 -10
  9. package/src/adapters/kiro.ts +172 -85
  10. package/src/adapters/mimo-free.ts +1 -0
  11. package/src/adapters/openai-chat.ts +30 -4
  12. package/src/adapters/openai-responses.ts +90 -12
  13. package/src/bridge.ts +91 -43
  14. package/src/claude/desktop-3p-paths.ts +84 -0
  15. package/src/claude/desktop-3p.ts +29 -2
  16. package/src/cli/access.ts +108 -0
  17. package/src/cli/account-auth.ts +223 -0
  18. package/src/cli/account.ts +9 -1
  19. package/src/cli/agent.ts +184 -0
  20. package/src/cli/combo.ts +119 -0
  21. package/src/cli/config-command.ts +145 -0
  22. package/src/cli/debug.ts +20 -8
  23. package/src/cli/doctor.ts +45 -8
  24. package/src/cli/help.ts +65 -13
  25. package/src/cli/index.ts +108 -7
  26. package/src/cli/integrations.ts +142 -0
  27. package/src/cli/models-runtime.ts +212 -0
  28. package/src/cli/models.ts +9 -10
  29. package/src/cli/observe.ts +117 -0
  30. package/src/cli/provider-runtime.ts +152 -0
  31. package/src/cli/provider.ts +23 -1
  32. package/src/cli/runtime-api.ts +325 -0
  33. package/src/cli/star-prompt.ts +3 -3
  34. package/src/cli/status.ts +17 -0
  35. package/src/cli/system-command.ts +112 -0
  36. package/src/codex/auth-api.ts +3 -2
  37. package/src/codex/catalog/aggregation.ts +113 -18
  38. package/src/codex/catalog/provider-fetch.ts +24 -13
  39. package/src/codex/catalog/sync.ts +20 -8
  40. package/src/codex/catalog.ts +2 -1
  41. package/src/codex/refresh.ts +10 -3
  42. package/src/codex/routing.ts +21 -32
  43. package/src/codex/sync.ts +17 -0
  44. package/src/config.ts +48 -0
  45. package/src/generated/jawcode-model-metadata.ts +2 -1
  46. package/src/grok/inject.ts +184 -4
  47. package/src/grok/status.ts +33 -0
  48. package/src/lib/retry-after.ts +55 -0
  49. package/src/lib/windows-elevation.ts +627 -0
  50. package/src/providers/openai-sidecar.ts +46 -2
  51. package/src/providers/registry.ts +52 -0
  52. package/src/server/auth-cors.ts +6 -0
  53. package/src/server/chat-completions.ts +6 -1
  54. package/src/server/claude-messages.ts +20 -1
  55. package/src/server/images.ts +14 -7
  56. package/src/server/management/agent-settings-routes.ts +10 -4
  57. package/src/server/management/combo-routes.ts +0 -1
  58. package/src/server/management/config-routes.ts +0 -1
  59. package/src/server/management/logs-usage-routes.ts +94 -0
  60. package/src/server/management/model-routes.ts +0 -1
  61. package/src/server/management/oauth-account-routes.ts +0 -1
  62. package/src/server/management/provider-routes.ts +0 -1
  63. package/src/server/management/shared.ts +0 -1
  64. package/src/server/management/system-routes.ts +27 -15
  65. package/src/server/management-api.ts +0 -1
  66. package/src/server/memory-watchdog.ts +54 -10
  67. package/src/server/request-log-conversation.ts +168 -0
  68. package/src/server/request-log.ts +122 -2
  69. package/src/server/responses/core.ts +76 -13
  70. package/src/server/responses/passthrough-error.ts +38 -13
  71. package/src/server/startup-action-control.ts +266 -15
  72. package/src/service.ts +512 -3
  73. package/src/storage/cleanup.ts +1538 -0
  74. package/src/storage/scanner.ts +4 -1
  75. package/src/types.ts +16 -0
  76. package/src/update/job.ts +229 -25
  77. package/src/usage/log.ts +39 -0
  78. package/src/web-search/loop.ts +8 -1
  79. package/gui/dist/assets/index-B2J4t3te.css +0 -1
  80. package/gui/dist/assets/index-BmvM6wRb.js +0 -65
@@ -33,7 +33,7 @@ import type { ProviderAdapter } from "./base";
33
33
  import type { AdapterFetchContext, AdapterRequest } from "./base";
34
34
  import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
35
35
  import { sniffImageDimensions } from "./anthropic-image-guard";
36
- import { fetchKiroWithRetry } from "./kiro-retry";
36
+ import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
37
37
  import { convertKiroToolContext } from "./kiro-tools";
38
38
  import { neutralizeIdentity } from "./identity";
39
39
  import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge";
@@ -42,6 +42,8 @@ import {
42
42
  KIRO_COMPLETION_RETRY_MESSAGE,
43
43
  KIRO_COMPLETION_TOOL_NAME,
44
44
  KIRO_CONTINUATION_MESSAGE,
45
+ KIRO_EMPTY_TOOL_RESULT_MESSAGE,
46
+ KIRO_TOOL_RESULT_CARRIER_MESSAGE,
45
47
  MAX_KIRO_INJECTED_INSTRUCTION_CHARS,
46
48
  type KiroCompletionMode,
47
49
  } from "./kiro-constants";
@@ -305,6 +307,51 @@ function appendTurnText(target: string, next: string): string {
305
307
  return target ? `${target}\n\n${next}` : next;
306
308
  }
307
309
 
310
+ function validateKiroConversationState(history: KiroHistoryEntry[], currentMessage: KiroHistoryEntry): void {
311
+ const entries = [...history, currentMessage];
312
+ const pendingToolUses = new Set<string>();
313
+ let previousRole: "user" | "assistant" | undefined;
314
+
315
+ for (const entry of entries) {
316
+ const user = entry.userInputMessage;
317
+ const assistant = entry.assistantResponseMessage;
318
+ if (Boolean(user) === Boolean(assistant)) {
319
+ throw new Error("Kiro conversation entries must contain exactly one message role");
320
+ }
321
+ const role = user ? "user" : "assistant";
322
+ if (role === previousRole) throw new Error("Kiro conversation roles must alternate");
323
+ previousRole = role;
324
+
325
+ if (user) {
326
+ const hasPayload = Boolean(user.content.trim())
327
+ || Boolean(user.images?.length)
328
+ || Boolean(user.userInputMessageContext?.toolResults?.length);
329
+ if (!hasPayload) throw new Error("Kiro user messages must not be empty");
330
+ for (const result of user.userInputMessageContext?.toolResults ?? []) {
331
+ if (!pendingToolUses.delete(result.toolUseId)) {
332
+ throw new Error(`Kiro tool result has no matching tool use ${JSON.stringify(result.toolUseId)}`);
333
+ }
334
+ if (!result.content.some(part => part.text.trim())) {
335
+ throw new Error(`Kiro tool result must not be empty ${JSON.stringify(result.toolUseId)}`);
336
+ }
337
+ }
338
+ continue;
339
+ }
340
+
341
+ const toolUses = assistant?.toolUses ?? [];
342
+ if (!assistant?.content.trim() && toolUses.length === 0) {
343
+ throw new Error("Kiro assistant messages must not be empty");
344
+ }
345
+ for (const toolUse of toolUses) {
346
+ if (pendingToolUses.has(toolUse.toolUseId)) {
347
+ throw new Error(`Kiro conversation contains duplicate tool use ${JSON.stringify(toolUse.toolUseId)}`);
348
+ }
349
+ pendingToolUses.add(toolUse.toolUseId);
350
+ }
351
+ }
352
+ if (pendingToolUses.size > 0) throw new Error("Kiro conversation contains an unanswered tool use");
353
+ }
354
+
308
355
  function boundedInjectedInstruction(text: string, used: { value: number }): string | undefined {
309
356
  const remaining = MAX_KIRO_INJECTED_INSTRUCTION_CHARS - used.value;
310
357
  if (remaining <= 0 || !text) return undefined;
@@ -423,31 +470,30 @@ export function buildKiroPayload(
423
470
  throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
424
471
  }
425
472
  const text = userContentText(tr.content);
473
+ const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
426
474
  const images = extractKiroImages(tr.content);
427
475
  const toolUseId = normalizeToolId(tr.toolCallId);
428
476
  if (!priorCalls.has(toolUseId)) {
429
477
  throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
430
478
  }
431
- pushUser("", images, [{
432
- content: [{ text }],
479
+ pushUser(KIRO_TOOL_RESULT_CARRIER_MESSAGE, images, [{
480
+ content: [{ text: resultText }],
433
481
  status: tr.isError ? "error" : "success",
434
482
  toolUseId,
435
483
  }]);
436
484
  }
437
485
  }
438
486
 
439
- // A reasoning-only first attempt has no Kiro-replayable assistant text. Preserve the turn
440
- // boundary structurally so the adapter-generated retry is still a user turn after assistant
441
- // history, without inventing prose that the model never said.
442
- if (completionMode === "text_fallback" && turns.at(-1)?.kind !== "assistant") {
443
- pushAssistant("", []);
444
- }
445
-
446
487
  if (turns.length === 0 || turns[0].kind === "assistant") {
447
488
  turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
448
489
  }
449
490
  if (turns.at(-1)?.kind === "assistant") {
450
- turns.push({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
491
+ turns.push({
492
+ kind: "user",
493
+ content: completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE,
494
+ images: [],
495
+ toolResults: [],
496
+ });
451
497
  }
452
498
 
453
499
  const currentTurn = turns.pop();
@@ -481,11 +527,14 @@ export function buildKiroPayload(
481
527
  currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
482
528
  }
483
529
  if (completionMode === "text_fallback") {
484
- currentUim.content = KIRO_COMPLETION_RETRY_MESSAGE;
530
+ if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE) {
531
+ currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE);
532
+ }
485
533
  } else if (!currentUim.userInputMessageContext?.toolResults && currentUim.content !== KIRO_CONTINUATION_MESSAGE) {
486
534
  currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
487
535
  }
488
536
 
537
+ validateKiroConversationState(history, currentEntry);
489
538
  const conversationId = stableConversationId(parsed);
490
539
  const payload: Record<string, unknown> = {
491
540
  conversationState: {
@@ -586,37 +635,35 @@ function retryableKiroIncomplete(
586
635
  message: string,
587
636
  usage: OcxUsage,
588
637
  providerState: { kiro: { conversationId: string } } | undefined,
638
+ retryable = true,
589
639
  ): AdapterEvent {
590
640
  return {
591
641
  type: "incomplete",
592
642
  reason,
593
643
  message,
594
644
  usage,
595
- retryable: true,
645
+ retryable,
596
646
  endTurn: false,
597
647
  ...(providerState ? { providerState } : {}),
598
648
  };
599
649
  }
600
650
 
601
651
  /**
602
- * Suppress only a whitespace-normalized exact repeat. Semantic/fuzzy matching was rejected
603
- * during review: two long near-identical messages can differ by a single status word
604
- * ("is still pending" -> "is now complete") and still clear every similarity threshold, so
605
- * fuzzy suppression can silently delete the one sentence that carries the result. Showing a
606
- * cosmetic restatement is the cheaper failure.
652
+ * Catch-path retryability for #519: only transport/socket failures with no emitted output
653
+ * are replay-safe. Malformed event payloads (`invalid Kiro …`) and any post-output failure
654
+ * stay terminal same spirit as cursor's emittedOutput gate.
607
655
  */
608
- function normalizedKiroAnswer(text: string): string {
609
- return text.trim().replace(/\s+/g, " ");
610
- }
611
-
612
- function isRepeatedKiroAnswer(text: string, previous?: string): boolean {
613
- return normalizedKiroAnswer(text) === normalizedKiroAnswer(previous ?? "");
656
+ export function isRetryableKiroStreamCatchError(err: unknown, emittedOutput: boolean): boolean {
657
+ if (emittedOutput) return false;
658
+ const message = err instanceof Error ? err.message : String(err);
659
+ if (/^invalid Kiro\b/i.test(message)) return false;
660
+ // Include Smithy/eventstream truncation (`eventstream: truncated message at end of stream`):
661
+ // partial frame + clean EOF with zero output is the same replay-safe class as a socket close.
662
+ 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
663
+ .test(message);
614
664
  }
615
665
 
616
- /**
617
- * Kiro's native stop reason for a turn the model considers finished. Only this value is
618
- * authoritative; `TOOL_USE` and an absent reason both leave the turn incomplete.
619
- */
666
+ /** Native clean-stop reason eligible for bounded private-completion validation. */
620
667
  const KIRO_END_TURN_STOP_REASON = "END_TURN";
621
668
 
622
669
  async function* parseKiroAttempt(
@@ -627,13 +674,12 @@ async function* parseKiroAttempt(
627
674
  contextWindowState: KiroContextWindowState,
628
675
  nameMap: Map<string, string> | undefined,
629
676
  conversationId: string | undefined,
630
- previousAssistantText?: string,
631
677
  contextInputEstimate?: number,
678
+ /** True when an earlier attempt already flushed visible content to the client (#520). */
679
+ priorEmittedOutput = false,
632
680
  ): AsyncGenerator<AdapterEvent, KiroAttemptResult> {
633
- // `required` mode holds staged commentary here so a terminal END_TURN can relabel it as the final
634
- // answer instead of paying for another inference request. Anything the inner parser leaves behind
635
- // — every early error return — is flushed before the terminal event so partial output is never
636
- // silently dropped.
681
+ // `required` mode holds staged commentary until a real tool call or terminal metadata identifies
682
+ // the attempt boundary. Anything the inner parser leaves behind is flushed before the terminal.
637
683
  const deferred: AdapterEvent[] = [];
638
684
  const attempt = parseKiroAttemptEvents(
639
685
  response,
@@ -644,8 +690,8 @@ async function* parseKiroAttempt(
644
690
  nameMap,
645
691
  conversationId,
646
692
  deferred,
647
- previousAssistantText,
648
693
  contextInputEstimate,
694
+ priorEmittedOutput,
649
695
  );
650
696
  let next = await attempt.next();
651
697
  while (!next.done) {
@@ -665,8 +711,8 @@ async function* parseKiroAttemptEvents(
665
711
  nameMap: Map<string, string> | undefined,
666
712
  conversationId: string | undefined,
667
713
  deferred: AdapterEvent[],
668
- previousAssistantText?: string,
669
714
  contextInputEstimate?: number,
715
+ priorEmittedOutput = false,
670
716
  ): AsyncGenerator<AdapterEvent, KiroAttemptResult> {
671
717
  const emptyResult = (): KiroAttemptResult => ({ assistantText: "", sawReasoning: false });
672
718
  if (!response.body) {
@@ -717,15 +763,30 @@ async function* parseKiroAttemptEvents(
717
763
  return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base;
718
764
  };
719
765
 
720
- const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => ({
721
- type: "error",
722
- message: failure.message,
723
- status: failure.status,
724
- errorType: failure.errorType,
725
- code: failure.code,
726
- retryable: failure.retryable,
727
- usage: usage(),
728
- });
766
+ const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => {
767
+ // Upstream exception/error frames can arrive after commentary was already staged (and will be
768
+ // flushed before this terminal is yielded). Replaying after that content would duplicate it.
769
+ const emittedOutput = priorEmittedOutput
770
+ || sawText
771
+ || sawReasoning
772
+ || sawRealTool
773
+ || assistantText.length > 0
774
+ || deferred.length > 0
775
+ || completionAnswer !== undefined
776
+ || completionCalls > 0
777
+ || open !== null
778
+ || fallbackEvents.length > 0;
779
+ if (failure.status === 429 && failure.retryable) noteKiroTransientThrottle();
780
+ return {
781
+ type: "error",
782
+ message: failure.message,
783
+ status: failure.status,
784
+ errorType: failure.errorType,
785
+ code: failure.code,
786
+ retryable: emittedOutput ? false : failure.retryable,
787
+ usage: usage(),
788
+ };
789
+ };
729
790
 
730
791
  const protocolTerminal = (message: string, malformedCompletion = false): AdapterEvent => {
731
792
  if (mode === "text_fallback" && malformedCompletion) {
@@ -734,6 +795,8 @@ async function* parseKiroAttemptEvents(
734
795
  message,
735
796
  usage(),
736
797
  providerState(),
798
+ // First-attempt progress was already flushed before this bounded fallback (#520).
799
+ !priorEmittedOutput,
737
800
  );
738
801
  }
739
802
  return {
@@ -1012,13 +1075,11 @@ async function* parseKiroAttemptEvents(
1012
1075
  ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}),
1013
1076
  });
1014
1077
  }
1015
- // Kiro's own end-of-turn verdict, trusted only when it is unambiguous: plain assistant text,
1016
- // with neither a real tool call nor a private completion call to arbitrate against it.
1017
- // STOP_SEQUENCE is an ordinary native completion just like END_TURN. Both must be decided
1018
- // here rather than further down, because the deferred text is relabeled to `final_answer`
1019
- // in this branch — once it flushes as commentary below, that chance is gone.
1078
+ // Native stop metadata proves that this inference ended, but it does not prove that ordinary
1079
+ // text is a final answer. Kiro has emitted END_TURN for progress prose, so tool-enabled turns
1080
+ // still require the private completion call to distinguish commentary from completion (#531).
1020
1081
  const normalizedStopReason = stopReason?.trim().toUpperCase();
1021
- const nativeEndTurn = (normalizedStopReason === KIRO_END_TURN_STOP_REASON
1082
+ const nativeCompletionStop = (normalizedStopReason === KIRO_END_TURN_STOP_REASON
1022
1083
  || normalizedStopReason === "STOP_SEQUENCE")
1023
1084
  && sawText
1024
1085
  && !sawRealTool
@@ -1031,31 +1092,19 @@ async function* parseKiroAttemptEvents(
1031
1092
  sawReasoning,
1032
1093
  sawRealTool,
1033
1094
  completionCalls,
1034
- nativeEndTurn,
1095
+ nativeCompletionStop,
1035
1096
  ...(stopReason !== undefined ? { stopReason } : {}),
1036
1097
  assistantChars: assistantText.length,
1037
1098
  });
1038
1099
 
1039
1100
  if (mode === "required") {
1040
- if (nativeEndTurn) {
1041
- for (const event of deferred.splice(0)) {
1042
- yield event.type === "text_delta" ? { ...event, phase: "final_answer" } : event;
1043
- }
1044
- return {
1045
- assistantText,
1046
- sawReasoning,
1047
- terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
1048
- };
1049
- }
1050
1101
  for (const event of deferred.splice(0)) yield event;
1051
1102
  }
1052
1103
 
1053
1104
  if (mode === "text_fallback") {
1054
1105
  if (completionAnswer !== undefined) {
1055
1106
  for (const event of fallbackEvents) yield event;
1056
- if (!isRepeatedKiroAnswer(completionAnswer, previousAssistantText)) {
1057
- yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
1058
- }
1107
+ yield { type: "text_delta", text: completionAnswer, phase: "final_answer" };
1059
1108
  return {
1060
1109
  assistantText,
1061
1110
  sawReasoning,
@@ -1071,10 +1120,9 @@ async function* parseKiroAttemptEvents(
1071
1120
  };
1072
1121
  }
1073
1122
  if (sawText) {
1074
- const repeated = isRepeatedKiroAnswer(assistantText, previousAssistantText);
1075
1123
  for (const event of fallbackEvents) {
1076
1124
  if (event.type !== "text_delta") yield event;
1077
- else if (!repeated) yield { ...event, phase: "final_answer" };
1125
+ else yield { ...event, phase: "final_answer" };
1078
1126
  }
1079
1127
  return {
1080
1128
  assistantText,
@@ -1093,6 +1141,8 @@ async function* parseKiroAttemptEvents(
1093
1141
  : "Kiro produced no final answer on its bounded completion retry",
1094
1142
  finalUsage,
1095
1143
  finalProviderState,
1144
+ // First-attempt progress was already flushed before this bounded fallback (#520).
1145
+ !priorEmittedOutput,
1096
1146
  ),
1097
1147
  };
1098
1148
  }
@@ -1112,13 +1162,23 @@ async function* parseKiroAttemptEvents(
1112
1162
  terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) },
1113
1163
  };
1114
1164
  }
1115
- // An explicit native stop reason has already terminated this inference. Converting it into
1165
+ if (mode === "required" && nativeCompletionStop) {
1166
+ return {
1167
+ assistantText,
1168
+ sawReasoning,
1169
+ needsFallback: true,
1170
+ usage: finalUsage,
1171
+ providerState: finalProviderState,
1172
+ };
1173
+ }
1174
+
1175
+ // An explicit non-completion stop reason has already terminated this inference. Converting it into
1116
1176
  // another model request would hide truncation behind a second paid call, and for context
1117
1177
  // exhaustion it would resubmit a request that cannot fit. Only a MISSING stop reason falls
1118
1178
  // through to the bounded compatibility fallback below.
1119
1179
  //
1120
- // END_TURN and STOP_SEQUENCE with text are handled earlier by `nativeEndTurn`; reaching
1121
- // here with either means the turn produced no text.
1180
+ // END_TURN and STOP_SEQUENCE with text take the bounded validation path above; reaching here
1181
+ // with either means the turn produced no replayable text.
1122
1182
  if (mode === "required" && normalizedStopReason !== undefined) {
1123
1183
  const providerStateField = finalProviderState ? { providerState: finalProviderState } : {};
1124
1184
  const incomplete = (reason: string, retryable: boolean) => ({
@@ -1195,6 +1255,22 @@ async function* parseKiroAttemptEvents(
1195
1255
  },
1196
1256
  };
1197
1257
  } catch (err) {
1258
+ // Mid-stream socket closes after response.created / heartbeats only must stay retryable:
1259
+ // nothing was relayed to the client, so a string-body replay is safe (see #519 / cursor's
1260
+ // emittedOutput gate). Once any assistant text, reasoning, tool, or deferred content exists
1261
+ // — including content flushed by a prior attempt before a bounded fallback — fail closed;
1262
+ // the client may already have partial output. Protocol parse throws stay non-retryable even
1263
+ // with zero output.
1264
+ const emittedOutput = priorEmittedOutput
1265
+ || sawText
1266
+ || sawReasoning
1267
+ || sawRealTool
1268
+ || assistantText.length > 0
1269
+ || deferred.length > 0
1270
+ || completionAnswer !== undefined
1271
+ || completionCalls > 0
1272
+ || open !== null
1273
+ || fallbackEvents.length > 0;
1198
1274
  return {
1199
1275
  assistantText,
1200
1276
  sawReasoning,
@@ -1204,7 +1280,7 @@ async function* parseKiroAttemptEvents(
1204
1280
  status: 502,
1205
1281
  errorType: "server_error",
1206
1282
  code: "kiro_stream_protocol_error",
1207
- retryable: false,
1283
+ retryable: isRetryableKiroStreamCatchError(err, emittedOutput),
1208
1284
  usage: usage(),
1209
1285
  },
1210
1286
  };
@@ -1231,7 +1307,6 @@ export async function* parseKiroStream(
1231
1307
  contextWindowState,
1232
1308
  nameMap,
1233
1309
  conversationId,
1234
- undefined,
1235
1310
  contextInputEstimate,
1236
1311
  );
1237
1312
  let firstNext = await first.next();
@@ -1255,6 +1330,10 @@ export async function* parseKiroStream(
1255
1330
  }
1256
1331
 
1257
1332
  yield { type: "heartbeat" };
1333
+ // First attempt already flushed deferred progress before this point. Gate fallback
1334
+ // setup/HTTP failures the same way as the second-stream catch so a replay cannot
1335
+ // duplicate visible commentary (#520).
1336
+ const priorEmittedOutput = Boolean(firstResult.assistantText.trim()) || firstResult.sawReasoning;
1258
1337
  let fallback: KiroFallbackAttempt;
1259
1338
  try {
1260
1339
  fallback = await fallbackFactory(
@@ -1268,7 +1347,7 @@ export async function* parseKiroStream(
1268
1347
  message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)),
1269
1348
  status: err instanceof Error && err.name === "TimeoutError" ? 504 : 502,
1270
1349
  errorType: "upstream_error",
1271
- retryable: true,
1350
+ retryable: !priorEmittedOutput,
1272
1351
  usage: firstResult.usage,
1273
1352
  };
1274
1353
  return;
@@ -1282,7 +1361,7 @@ export async function* parseKiroStream(
1282
1361
  status: failure.status,
1283
1362
  errorType: failure.errorType,
1284
1363
  code: failure.code,
1285
- retryable: failure.retryable,
1364
+ retryable: priorEmittedOutput ? false : failure.retryable,
1286
1365
  usage: firstResult.usage,
1287
1366
  };
1288
1367
  return;
@@ -1296,8 +1375,10 @@ export async function* parseKiroStream(
1296
1375
  contextWindowState,
1297
1376
  fallback.nameMap,
1298
1377
  fallback.conversationId,
1299
- firstResult.assistantText,
1300
1378
  fallback.contextInputEstimate,
1379
+ // First attempt already flushed deferred progress to the client before this fallback.
1380
+ // A zero-output transport failure here must stay non-retryable to avoid duplicating that text.
1381
+ priorEmittedOutput,
1301
1382
  );
1302
1383
  let secondNext = await second.next();
1303
1384
  while (!secondNext.done) {
@@ -1312,12 +1393,17 @@ export async function* parseKiroStream(
1312
1393
  mergeKiroUsage(firstResult.usage, secondResult.usage, Boolean(firstResult.assistantText))
1313
1394
  ?? { inputTokens, outputTokens: 0, estimated: true },
1314
1395
  secondResult.providerState ?? firstResult.providerState,
1396
+ !priorEmittedOutput,
1315
1397
  );
1316
1398
  return;
1317
1399
  }
1318
1400
  if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") {
1319
1401
  yield {
1320
1402
  ...secondResult.terminal,
1403
+ // Belt-and-suspenders: never advertise a replay-safe incomplete after flushed progress.
1404
+ ...(secondResult.terminal.type === "incomplete" && priorEmittedOutput
1405
+ ? { retryable: false as const }
1406
+ : {}),
1321
1407
  usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, Boolean(firstResult.assistantText)),
1322
1408
  providerState: secondResult.terminal.providerState ?? firstResult.providerState,
1323
1409
  };
@@ -1407,7 +1493,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1407
1493
  const fallbackFactory: KiroFallbackFactory = async (
1408
1494
  returnedConversationId,
1409
1495
  assistantText,
1410
- sawReasoning,
1496
+ _sawReasoning,
1411
1497
  ) => {
1412
1498
  if (!requestSnapshot) throw new Error("Kiro completion retry lost its request state");
1413
1499
  if (requestAbortSignal?.aborted) {
@@ -1420,16 +1506,17 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1420
1506
  ...(retryParsed._providerContinuation ?? {}),
1421
1507
  ...(returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : {}),
1422
1508
  };
1423
- retryParsed.context.messages.push({
1424
- role: "assistant",
1425
- content: [
1426
- ...(sawReasoning ? [{ type: "thinking" as const, thinking: "" }] : []),
1427
- ...(assistantText ? [{ type: "text" as const, text: assistantText }] : []),
1428
- ],
1429
- phase: "commentary",
1430
- model: retryParsed.modelId,
1431
- timestamp: Date.now(),
1432
- });
1509
+ // Reasoning is not replayable on the Kiro wire. Adding an empty assistant turn merely to mark
1510
+ // that reasoning existed creates REQUEST_BODY_INVALID; only visible text earns a replay turn.
1511
+ if (assistantText.trim()) {
1512
+ retryParsed.context.messages.push({
1513
+ role: "assistant",
1514
+ content: [{ type: "text" as const, text: assistantText }],
1515
+ phase: "commentary",
1516
+ model: retryParsed.modelId,
1517
+ timestamp: Date.now(),
1518
+ });
1519
+ }
1433
1520
  const retry = await build(retryParsed, "text_fallback");
1434
1521
  const response = await fetchKiroWithRetry(retry.request, {
1435
1522
  abortSignal: requestAbortSignal,
@@ -191,6 +191,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
191
191
  method: "POST",
192
192
  headers,
193
193
  body: JSON.stringify(markedBody),
194
+ ...(baseReq.reasoningLog ? { reasoningLog: baseReq.reasoningLog } : {}),
194
195
  };
195
196
  },
196
197
 
@@ -1,4 +1,4 @@
1
- import type { ProviderAdapter } from "./base";
1
+ import type { AdapterRequest, ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
3
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
@@ -349,7 +349,9 @@ function ensureZenRootObjectSchema(schema: unknown): Record<string, unknown> {
349
349
  }
350
350
 
351
351
  function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
352
- return provider.baseUrl.replace(/\/+$/, "") === "https://opencode.ai/zen/v1";
352
+ const baseUrl = provider.baseUrl.replace(/\/+$/, "");
353
+ return baseUrl === "https://opencode.ai/zen/v1"
354
+ || baseUrl === "https://opencode.ai/zen/go/v1";
353
355
  }
354
356
 
355
357
  const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
@@ -544,19 +546,37 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
544
546
  }
545
547
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
546
548
  const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
549
+ let reasoningLog: AdapterRequest["reasoningLog"];
547
550
  if (reasoningEffort !== undefined) {
548
551
  if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
549
552
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
550
- if (budget !== undefined) body.thinking_budget = budget;
553
+ if (budget !== undefined) {
554
+ body.thinking_budget = budget;
555
+ reasoningLog = {
556
+ effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort,
557
+ wireField: "thinking_budget",
558
+ wireValue: budget,
559
+ };
560
+ }
551
561
  } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
552
562
  // Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
553
563
  // these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
554
564
  // uses adaptive/disabled.
555
565
  if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
556
566
  body.thinking = { type: reasoningEffort };
567
+ reasoningLog = {
568
+ effectiveEffort: reasoningEffort,
569
+ wireField: "thinking.type",
570
+ wireValue: reasoningEffort,
571
+ };
557
572
  }
558
573
  } else {
559
574
  body.reasoning_effort = reasoningEffort;
575
+ reasoningLog = {
576
+ effectiveEffort: reasoningEffort,
577
+ wireField: "reasoning_effort",
578
+ wireValue: reasoningEffort,
579
+ };
560
580
  }
561
581
  }
562
582
  if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
@@ -610,7 +630,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
610
630
  });
611
631
  }
612
632
 
613
- return { url, method: "POST", headers, body: bodyJson };
633
+ return {
634
+ url,
635
+ method: "POST",
636
+ headers,
637
+ body: bodyJson,
638
+ ...(reasoningLog ? { reasoningLog } : {}),
639
+ };
614
640
  },
615
641
 
616
642
  async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {