Skip to content
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 @@ -442,6 +442,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++;
};
// 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 @@ -869,6 +892,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 @@ -1039,6 +1068,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 @@ -1361,6 +1393,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 @@ -1528,6 +1564,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 @@ -1626,6 +1672,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 @@ -416,6 +416,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