Skip to content
Merged
11 changes: 11 additions & 0 deletions src/adapters/google-truncation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ export function vertexTruncationErrorMessage(reason?: string): string {
const suffix = reason ? ` (${redactSecretString(reason).slice(0, 160)})` : "";
return `Vertex AI response truncated upstream before the turn completed${suffix}`;
}

/**
* Whether a finished turn must fail closed. A truncation reason arriving mid tool call always
* does. MALFORMED_FUNCTION_CALL fails closed even with zero started calls: the malformed call
* is dropped upstream and usually never materializes as a part, so the turn is incomplete
* despite looking empty. MAX_TOKENS with no started call stays a plain token-limit stop.
*/
export function isVertexTruncatedTurn(finishReason: string | undefined, toolCallsStarted: number): boolean {
if (!isVertexTruncationReason(finishReason)) return false;
return toolCallsStarted > 0 || finishReason === "MALFORMED_FUNCTION_CALL";
}
6 changes: 3 additions & 3 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { contentPartsToText, parseDataUrl } from "./image";
import { getVertexAccessToken } from "../lib/gcp-adc";
import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google-truncation";
import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation";
import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
import { compileGoogleWireBody } from "./google-wire-compiler";
import { identifyRoutedModel } from "./identity";
Expand Down Expand Up @@ -596,7 +596,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// Fail-closed: a turn cut off mid tool call (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces
// an error instead of a silently-incomplete done. Mirrors kiro-truncation.
if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
&& toolCallsStarted > 0 && isVertexTruncationReason(lastFinishReason)) {
&& isVertexTruncatedTurn(lastFinishReason, toolCallsStarted)) {
yield { type: "error", message: vertexTruncationErrorMessage(lastFinishReason) };
return;
}
Expand Down Expand Up @@ -752,7 +752,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call
// (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
&& toolCallsStarted > 0 && isVertexTruncationReason(candidates?.[0]?.finishReason)) {
&& isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) {
return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]);
}

Expand Down
20 changes: 19 additions & 1 deletion src/adapters/kiro-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { kiroTruncationReason } from "./kiro-truncation";

export type ParsedKiroEvent =
| { type: "content"; data?: string; modelId?: string }
| { type: "reasoning"; data?: string }
| { type: "reasoning"; data?: string; redactedContent?: string }
| { type: "context_usage"; contextUsagePercentage: number }
| { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
| { type: "truncation"; data: string }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
Expand All @@ -17,6 +18,10 @@ const KNOWN_EVENT_TYPES = new Set([
"toolUseEvent",
"messageMetadataEvent",
"metadataEvent",
// Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
// HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
// contextUsagePercentage stays supported as a fallback rather than being dropped.
"contextUsageEvent",
"invalidStateEvent",
"error",
]);
Expand Down Expand Up @@ -114,11 +119,17 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
: {}),
};
case "reasoningContentEvent":
// `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family
// (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not
// the model id. Both may be absent on a bare event.
return {
type: "reasoning",
...(optionalString(eventType, parsed, "text") !== undefined
? { data: optionalString(eventType, parsed, "text") }
: {}),
...(optionalString(eventType, parsed, "redactedContent") !== undefined
? { redactedContent: optionalString(eventType, parsed, "redactedContent") }
: {}),
};
case "toolUseEvent":
return {
Expand Down Expand Up @@ -161,6 +172,13 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
...(stopReason !== undefined ? { stopReason } : {}),
};
}
case "contextUsageEvent": {
const contextUsagePercentage = parsed.contextUsagePercentage;
if (typeof contextUsagePercentage !== "number" || !Number.isFinite(contextUsagePercentage)) {
return malformed(eventType, "contextUsagePercentage must be a finite number");
}
return { type: "context_usage", contextUsagePercentage };
}
case "invalidStateEvent":
return { type: "invalid_state", message: optionalString(eventType, parsed, "message") };
case "error":
Expand Down
23 changes: 18 additions & 5 deletions src/adapters/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ interface KiroUserInputMessage {
}
interface KiroHistoryEntry {
userInputMessage?: KiroUserInputMessage;
assistantResponseMessage?: { content: string; toolUses?: KiroToolUse[] };
assistantResponseMessage?: {
content: string;
toolUses?: KiroToolUse[];
reasoningContent?: { redactedContent: string };
};
}

function kiroToolWireNames(tools: readonly unknown[]): string[] {
Expand Down Expand Up @@ -326,7 +330,7 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {

type KiroTurn =
| { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
| { kind: "assistant"; content: string; toolUses: KiroToolUse[] };
| { kind: "assistant"; content: string; toolUses: KiroToolUse[]; redactedReasoning?: string };

function appendTurnText(target: string, next: string): string {
if (!next) return target;
Expand Down Expand Up @@ -471,13 +475,15 @@ export function buildKiroPayload(
turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
}
};
const pushAssistant = (content: string, toolUses: KiroToolUse[]): void => {
const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
const last = turns.at(-1);
if (last?.kind === "assistant") {
last.content = appendTurnText(last.content, content);
last.toolUses.push(...toolUses);
// Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
if (redactedReasoning) last.redactedReasoning = redactedReasoning;
} else {
turns.push({ kind: "assistant", content, toolUses: [...toolUses] });
turns.push({ kind: "assistant", content, toolUses: [...toolUses], ...(redactedReasoning ? { redactedReasoning } : {}) });
}
};

Expand Down Expand Up @@ -507,7 +513,7 @@ export function buildKiroPayload(
const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
if (hasReasoning) continue;
}
pushAssistant(text, toolUses);
pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning);
} else if (msg.role === "toolResult") {
const tr = msg as OcxToolResultMessage;
if (tr.containsEncryptedContent) {
Expand Down Expand Up @@ -561,6 +567,7 @@ export function buildKiroPayload(
assistantResponseMessage: {
content: turn.content,
...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}),
...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}),
},
}
: {
Expand Down Expand Up @@ -1183,6 +1190,12 @@ async function* parseKiroAttemptEvents(
if (ev.data) {
yield* emitRetained(stage({ type: "reasoning_raw_delta", text: ev.data }));
}
if (ev.redactedContent) {
yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: ev.redactedContent }));
}
break;
case "context_usage":
if (ev.contextUsagePercentage > 0) contextUsagePercentage = ev.contextUsagePercentage;
break;
case "tool": {
for (const contentEvent of thinking.flush()) {
Expand Down
55 changes: 55 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,29 @@ export function bridgeToResponsesSSE(
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
// Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the
// assistant message is still open, so this CANNOT emit on arrival: the open message still
// owns `outputIndex` (it only advances on close), and an item emitted here would both reuse
// that index and land BEFORE the message — where the parser's backwards pairing drops it as
// orphaned. Stash it and flush after `done` has closed every open item instead.
let pendingKiroRedacted: string | undefined;
let pendingKiroRedactedBytes = 0;
const flushKiroRedactedReasoning = () => {
if (!pendingKiroRedacted) return;
const previousBytes = pendingKiroRedactedBytes;
const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted });
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
pendingKiroRedacted = undefined;
pendingKiroRedactedBytes = 0;
reservation?.commitRetained();
budget?.releaseRetained(previousBytes, { kind: "reasoning" });
const itemId = `rs_${uuid()}`;
const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
emit("response.output_item.added", { output_index: outputIndex, item });
emit("response.output_item.done", { output_index: outputIndex, item });
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
Comment on lines +460 to +482

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Flush the Kiro blob on every terminal path.

Lines 1100-1102 flush pendingKiroRedacted only for done. The incomplete, error, adapter-EOF, and stall-timeout paths do not flush or release it. A stream that receives kiro_redacted_reasoning and then terminates abnormally loses the replay state. It also leaves the retained blob charged in an external TranslatorBudget.

Call flushKiroRedactedReasoning() after closing open output items in every terminal path, before emitting the terminal Responses event. Add regression tests for incomplete and error after a Kiro blob. This aligns streaming behavior with batch finalization at Lines 1717-1725.

Also applies to: 921-926, 1100-1102

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bridge.ts` around lines 460 - 482, Update every terminal streaming path
in the bridge, including incomplete, error, adapter-EOF, and stall-timeout
handling, to call flushKiroRedactedReasoning() after closing open output items
and before emitting the terminal Responses event. Preserve the existing done
behavior, ensure pending data is released and emitted on abnormal termination,
and add regression coverage for incomplete and error after receiving a Kiro
blob.

// Full assistant text of a compaction turn (across message boundaries) — becomes the
// synthetic compaction item's payload on done.
let compactionText = "";
Expand Down Expand Up @@ -895,6 +918,12 @@ export function bridgeToResponsesSSE(
pendingRedacted.push(event.data);
break;
}
case "kiro_redacted_reasoning": {
// Stash only — see flushKiroRedactedReasoning. One blob per turn, so last wins.
pendingKiroRedactedBytes = replaceRetainedString(pendingKiroRedactedBytes, event.data, "reasoning");
pendingKiroRedacted = event.data;
break;
}
case "reasoning_raw_delta": {
if (options?.hideThinkingSummary) {
({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString(
Expand Down Expand Up @@ -1068,6 +1097,9 @@ export function bridgeToResponsesSSE(
// Redacted-only turns (or hidden thinking without a trailing signature event) still
// need their envelope-only reasoning item so the blocks replay next turn.
flushHiddenReasoningEnvelope();
// After every close above, so the blob lands AFTER the assistant message it belongs
// to and the parser's backwards pairing finds it.
flushKiroRedactedReasoning();
if (options?.compaction) {
// Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
const item = {
Expand Down Expand Up @@ -1396,6 +1428,10 @@ function buildResponseJSONWithBudget(
let batchSignatureBytes = 0;
let batchRedacted: string[] = [];
let batchRedactedBytes = 0;
// Kiro reasoning blob, held until after the trailing flushes so it lands AFTER the assistant
// message (see the streaming path). Retained because it outlives releaseTranslatedEvent.
let batchKiroRedacted: string | undefined;
let batchKiroRedactedBytes = 0;
let currentToolCallId = "";
let currentToolCallName = "";
let currentToolCallArgs = "";
Expand Down Expand Up @@ -1567,6 +1603,16 @@ function buildResponseJSONWithBudget(
}
batchRedacted.push(e.data);
break;
case "kiro_redacted_reasoning":
// Stash only — pushed after the trailing flushes. One blob per turn, so last wins.
{
const dataBytes = bytesOf(e.data);
budget?.chargeRetained(dataBytes, { kind: "reasoning" });
if (batchKiroRedactedBytes > 0) budget?.releaseRetained(batchKiroRedactedBytes, { kind: "reasoning" });
batchKiroRedactedBytes = dataBytes;
}
batchKiroRedacted = e.data;
break;
case "reasoning_raw_delta":
if (currentText) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
Expand Down Expand Up @@ -1668,6 +1714,15 @@ function buildResponseJSONWithBudget(
flushRawReasoning();
// Open tool call on a failed/incomplete turn must not land as status:"completed".
if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
if (batchKiroRedacted) {
// pushOutput reserves the item itself and releases the retained raw blob it replaces.
pushOutput({
type: "reasoning", id: `rs_${uuid()}`, summary: [],
encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }),
}, batchKiroRedactedBytes, "reasoning");
batchKiroRedacted = undefined;
batchKiroRedactedBytes = 0;
}
// A truncated turn must never be installed as replacement history: emit the
// compaction item only when the turn actually completed (#422).
if (
Expand Down
4 changes: 4 additions & 0 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ function toolResultOutput(block: Rec): string | Rec[] {
} else if (item.type === "image") {
const img = imageBlockToInputImage(item);
if (img) out.push(img);
} else if (item.type === "document") {
// Same marker as the user-message document case below: the model should see the
// attachment happened instead of an empty tool output.
out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` });
}
}
if (isError) out.unshift({ type: "input_text", text: "[tool error]" });
Expand Down
17 changes: 17 additions & 0 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ interface OpenBlock {
argsBufBytes?: number;
webSearchArgsEmitted?: boolean;
callId?: string;
/** Last reasoning part identity (item + summary/content index) seen by this thinking block. */
reasoningPartKey?: string;
}

/** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
Expand Down Expand Up @@ -353,6 +355,21 @@ export function responsesSseToAnthropicSse(
case "response.reasoning_text.delta": {
if (typeof data.delta !== "string" || data.delta.length === 0) break;
ensureBlock("thinking");
// The JSON path joins reasoning summary/content parts with "\n\n"
// (responsesJsonToAnthropicMessage); mirror that at part and item boundaries
// so multi-part summaries do not glue into one run-on paragraph. Frames
// without part indices produce a constant key and never get a separator.
const slot = eventName === "response.reasoning_summary_text.delta"
? `s${String(data.summary_index)}`
: `c${String(data.content_index)}`;
const partKey = `${String(data.item_id)}:${slot}`;
if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) {
emit("content_block_delta", {
type: "content_block_delta", index: open!.index,
delta: { type: "thinking_delta", thinking: "\n\n" },
});
}
open!.reasoningPartKey = partKey;
emit("content_block_delta", {
type: "content_block_delta", index: open!.index,
delta: { type: "thinking_delta", thinking: data.delta },
Expand Down
3 changes: 2 additions & 1 deletion src/lib/bun-stream-caps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry
* that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is
* "known-bad" until a bundle-bump commit sets it. Windows no-rewrite traffic
* follows this runtime/config decision. Darwin no-rewrite traffic stays on tee
* follows this runtime/config decision, preserving the explicit legacy-tee
* safety pin. Darwin no-rewrite traffic stays on tee
* for `auto` regardless of runtime capability and reaches eager relay only via
* explicit `streamMode: "eager-relay"` opt-in (see
* devlog/_plan/260731_macos_rss_retention/100_darwin_eager_optin.md).
Expand Down
12 changes: 12 additions & 0 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,18 @@ export function parseRequest(body: unknown): OcxParsedRequest {
: null;
const thinkingText = envelope?.txt || text;

// Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider
// state for the assistant turn that ALREADY closed, because Kiro emits its
// reasoningContentEvent at the END of a turn (after content AND tool calls, verified
// against kiro-cli 2.14.1/2.16.0). Folding it into the FOLLOWING turn like ordinary
// reasoning would attach turn N's blob to turn N+1, so attach it backwards instead. With
// no assistant turn to own it the blob is dropped rather than mis-paired.
if (envelope?.krc && thinkingText.length === 0) {
const previous = messages[messages.length - 1];
if (previous?.role === "assistant") previous.kiroRedactedReasoning = envelope.krc;
continue;
}

// Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
// assistant turn or invent replayable plaintext/signatures from the encrypted payload.
if (thinkingText.length > 0) {
Expand Down
10 changes: 9 additions & 1 deletion src/responses/reasoning-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface ReasoningEnvelope {
* so replay needs it even though the visible summary was suppressed.
*/
txt?: string;
/**
* Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to
* the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve
* model reasoning across turns, so it round-trips here the same way a signature does.
*/
krc?: string;
}

export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
Expand All @@ -45,7 +51,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
}
const txt = (parsed as { txt?: unknown }).txt;
if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
return envelope.sig || envelope.red || envelope.txt ? envelope : null;
const krc = (parsed as { krc?: unknown }).krc;
if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
} catch {
return null;
}
Expand Down
Loading
Loading