From cbb5d21ec2d333f7cc1acd9ac78aca95ea997116 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:33:26 +0200 Subject: [PATCH 1/5] fix(kiro): round-trip the redactedContent reasoning blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro never returns plaintext reasoning for its Sol-family models. Its `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, and `gpt-5.6-sol`'s `additionalModelRequestFieldsSchema` accepts only `reasoning.effort` — there is no display or summary opt-in. Kiro's own CLI replays that blob on the matching `assistantResponseMessage.reasoningContent` to preserve model reasoning across turns. The adapter read only `reasoningContentEvent.text`, which is absent on this wire, so the blob was dropped and never replayed. Every turn therefore restarted without the previous turn's reasoning. - kiro-events: parse `redactedContent`; add the previously unhandled `contextUsageEvent` (Kiro reports context pressure there, not in `metadataEvent`, which carries only `stopReason`). - Carry the blob through the existing `ocxr1:` envelope as `krc` on an envelope-only reasoning item, so it round-trips while staying invisible in the app — the same contract the hidden-thinking path already uses. - Pair it backwards: Kiro emits the event at the END of a turn, after content and tool calls, so a krc-only item belongs to the assistant turn that already closed. Folding it forward would attach turn N's blob to turn N+1. With no assistant turn to own it, the blob is dropped rather than mis-paired. - Replay it on `assistantResponseMessage.reasoningContent`. Verified against kiro-cli 2.14.1 and 2.16.0 request/response captures. --- src/adapters/kiro-events.ts | 18 +++++++++++- src/adapters/kiro.ts | 23 +++++++++++---- src/bridge.ts | 24 +++++++++++++++ src/responses/parser.ts | 12 ++++++++ src/responses/reasoning-envelope.ts | 10 ++++++- src/types.ts | 9 ++++++ structure/04_transports-and-sidecars.md | 24 +++++++++++++++ tests/anthropic-thinking-signature.test.ts | 33 +++++++++++++++++++++ tests/kiro-adapter.test.ts | 26 +++++++++++++++++ tests/kiro-stream.test.ts | 34 ++++++++++++++++++++++ 10 files changed, 206 insertions(+), 7 deletions(-) diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 0def314cc..2e55369e6 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -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 } @@ -17,6 +18,9 @@ const KNOWN_EVENT_TYPES = new Set([ "toolUseEvent", "messageMetadataEvent", "metadataEvent", + // Kiro reports context pressure in its OWN event type, not inside metadataEvent (verified + // against kiro-cli 2.14.1 and 2.16.0: metadataEvent carries only `stopReason`). + "contextUsageEvent", "invalidStateEvent", "error", ]); @@ -114,11 +118,16 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi : {}), }; case "reasoningContentEvent": + // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the model actually + // returns for Sol-family models. 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 { @@ -161,6 +170,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": diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 3e2440045..2ea1bc84d 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -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[] { @@ -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; @@ -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 } : {}) }); } }; @@ -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) { @@ -561,6 +567,7 @@ export function buildKiroPayload( assistantResponseMessage: { content: turn.content, ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}), + ...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}), }, } : { @@ -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()) { diff --git a/src/bridge.ts b/src/bridge.ts index 21fa85118..bb625f58b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -869,6 +869,21 @@ export function bridgeToResponsesSSE( pendingRedacted.push(event.data); break; } + case "kiro_redacted_reasoning": { + // Opaque and unrenderable: emit an envelope-only reasoning item (no summary, no text + // deltas) so it stays invisible in the app while still round-tripping. Kiro sends it + // once the turn's content and tool calls are done, so nothing open is disturbed. + const encrypted = encodeReasoningEnvelope({ krc: event.data }); + const reservation = budget?.reserveTransient(bytesOf(encrypted), { 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 }); + reservation?.commitRetained(); + retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning"); + outputIndex++; + break; + } case "reasoning_raw_delta": { if (options?.hideThinkingSummary) { ({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString( @@ -1528,6 +1543,15 @@ function buildResponseJSONWithBudget( } batchRedacted.push(e.data); break; + case "kiro_redacted_reasoning": + // Envelope-only reasoning item, same contract as the streaming path. + { + const encrypted = encodeReasoningEnvelope({ krc: e.data }); + pushOutput({ + type: "reasoning", id: `rs_${uuid()}`, summary: [], encrypted_content: encrypted, + }, bytesOf(encrypted), "reasoning"); + } + break; case "reasoning_raw_delta": if (currentText) flushText("commentary"); if (currentSummaryReasoning) flushSummaryReasoning(); diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 339fd0002..cdfb1ff33 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -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) { diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 18c6bc639..1735f775f 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -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 { @@ -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; } diff --git a/src/types.ts b/src/types.ts index 0bbdc7e41..faceb9968 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,12 @@ export interface OcxAssistantMessage { phase?: OcxMessagePhase; model?: string; timestamp: number; + /** + * Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob + * Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so + * it rides the message rather than a content part: any other adapter simply ignores it. + */ + kiroRedactedReasoning?: string; } export interface OcxDeveloperMessage { @@ -254,6 +260,9 @@ export type AdapterEvent = // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400. | { type: "thinking_signature"; signature: string } | { type: "redacted_thinking"; data: string } + // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn. + // Never rendered — it only rides the reasoning item's envelope so the next request can replay it. + | { type: "kiro_redacted_reasoning"; data: string } | { type: "reasoning_raw_delta"; text: string } | { type: "tool_call_start"; id: string; name: string } | { type: "tool_call_delta"; arguments: string } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index b58505267..22b7ac2dc 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -381,6 +381,30 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden `fetchWithHeaderTimeout` takes an executor so provider fetch wrappers stay inside the timeout race. +## Kiro reasoning round-trip (`redactedContent`) + +Kiro never returns plaintext reasoning for its Sol-family models: `reasoningContentEvent` carries a +KMS-encrypted `redactedContent` blob, and `gpt-5.6-sol`'s `additionalModelRequestFieldsSchema` +(`ListAvailableModels`) accepts only `reasoning.effort` — there is no display/summary opt-in. Kiro's +own CLI replays that blob on the matching `assistantResponseMessage.reasoningContent` to preserve +model reasoning across turns; dropping it makes every turn restart without the previous turn's +reasoning. Verified against kiro-cli 2.14.1 and 2.16.0. + +- The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on + an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the + Codex app while round-tripping, exactly like the hidden-thinking path. +- **Pairing is backwards.** Kiro emits `reasoningContentEvent` at the END of an assistant turn, + after content AND tool calls. A `krc`-only item therefore belongs to the turn that already + closed, so the parser attaches it to the PRECEDING assistant message rather than folding it into + the following turn like ordinary reasoning (`src/responses/parser.ts`). With no assistant turn to + own it, the blob is dropped rather than mis-paired. +- The blob lives on `OcxAssistantMessage.kiroRedactedReasoning`, not on a thinking content part, so + no other adapter replays provider-private state if the conversation switches providers. + +Kiro also reports context pressure in its own `contextUsageEvent`; `metadataEvent` carries only +`stopReason`. Spend arrives in `meteringEvent` as **credits, not tokens** — there is no `tokenUsage` +on this wire at all, which is why Kiro usage stays estimated. + ## Parallel tool calls (default-on for chat providers) The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to diff --git a/tests/anthropic-thinking-signature.test.ts b/tests/anthropic-thinking-signature.test.ts index 14180680a..e1227e33a 100644 --- a/tests/anthropic-thinking-signature.test.ts +++ b/tests/anthropic-thinking-signature.test.ts @@ -166,6 +166,39 @@ describe("parser ocxr1 decode + anthropic replay", () => { expect(thinking?.redacted).toEqual(["RED1"]); }); + // Kiro emits its reasoningContentEvent at the END of an assistant turn (after content AND tool + // calls), so a krc-only envelope belongs to the turn BEFORE it. Folding it forward like ordinary + // reasoning would attach turn N's blob to turn N+1 and hand Kiro a mismatched blob. + test("krc-only reasoning attaches to the preceding assistant turn", async () => { + const parsed = parseRequest({ + model: "kiro/gpt-5.6-sol", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "first" }] }, + { type: "reasoning", id: "rs_1", summary: [], encrypted_content: encodeReasoningEnvelope({ krc: "BLOB1" }) }, + { type: "message", role: "user", content: [{ type: "input_text", text: "more" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "second" }] }, + ], + }); + const assistants = parsed.context.messages.filter(m => m.role === "assistant"); + expect(assistants).toHaveLength(2); + expect((assistants[0] as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe("BLOB1"); + expect((assistants[1] as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBeUndefined(); + }); + + test("krc-only reasoning with no preceding assistant turn is dropped, not mis-paired", async () => { + const parsed = parseRequest({ + model: "kiro/gpt-5.6-sol", + input: [ + { type: "reasoning", id: "rs_1", summary: [], encrypted_content: encodeReasoningEnvelope({ krc: "ORPHAN" }) }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant"); + expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBeUndefined(); + }); + test("hidden signed text (txt) is restored as the thinking body", async () => { const encrypted = encodeReasoningEnvelope({ sig: "RealSig1234567890==", txt: "the hidden signed text" }); const parsed = parseRequest({ diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 2288567eb..2cc49fb38 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -268,6 +268,32 @@ describe("kiro adapter — buildRequest", () => { expect(results[0].status).toBe("success"); }); + // Kiro's own client replays the encrypted reasoning blob on the assistant turn it belongs to; + // dropping it makes every turn start without the previous turn's reasoning. + test("assistant history replays the Kiro redacted reasoning blob", async () => { + const messages = [ + { role: "user", content: "think" }, + { role: "assistant", content: [{ type: "text", text: "answer" }], kiroRedactedReasoning: "LktUUn5+blob" }, + { role: "user", content: "again" }, + ]; + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages)); + const arm = JSON.parse(body).conversationState.history + .find((h: { assistantResponseMessage?: unknown }) => h.assistantResponseMessage)?.assistantResponseMessage; + expect(arm.reasoningContent).toEqual({ redactedContent: "LktUUn5+blob" }); + }); + + test("assistant history omits reasoningContent when no blob was captured", async () => { + const messages = [ + { role: "user", content: "think" }, + { role: "assistant", content: [{ type: "text", text: "answer" }] }, + { role: "user", content: "again" }, + ]; + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages)); + const arm = JSON.parse(body).conversationState.history + .find((h: { assistantResponseMessage?: unknown }) => h.assistantResponseMessage)?.assistantResponseMessage; + expect(arm).not.toHaveProperty("reasoningContent"); + }); + test("empty tool output is normalized to a non-empty Kiro result block", async () => { const messages = [ { role: "user", content: "run it" }, diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 4de16927f..b2a92bdf1 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -1306,6 +1306,40 @@ describe("kiro adapter — parseStream", () => { ]); }); + // Kiro's Sol-family models never return plaintext reasoning: reasoningContentEvent carries an + // encrypted `redactedContent` blob (verified against kiro-cli 2.14.1 and 2.16.0), which the + // official client replays on the matching assistantResponseMessage to preserve reasoning across + // turns. Reading only `text` dropped it entirely. + test("reasoningContentEvent redactedContent is captured for round-trip", async () => { + const events = await collectAdapterEvents(createKiroAdapter(provider).parseStream(new Response(streamOf( + eventFrame({ content: "visible answer" }), + eventFrame({ redactedContent: "LktUUn5+encrypted" }, "reasoningContentEvent"), + )))); + expect(events).toEqual([ + { type: "text_delta", text: "visible answer" }, + { type: "kiro_redacted_reasoning", data: "LktUUn5+encrypted" }, + expect.objectContaining({ type: "done", endTurn: true }), + ]); + }); + + test("reasoningContentEvent carrying both text and redactedContent emits both", async () => { + const events = await collectAdapterEvents(createKiroAdapter(provider).parseStream(new Response(streamOf( + eventFrame({ text: "plain", redactedContent: "blob" }, "reasoningContentEvent"), + )))); + expect(events).toEqual([ + { type: "reasoning_raw_delta", text: "plain" }, + { type: "kiro_redacted_reasoning", data: "blob" }, + expect.objectContaining({ type: "done" }), + ]); + }); + + // Kiro reports context pressure in its own event type; metadataEvent carries only stopReason, so + // reading contextUsagePercentage from metadataEvent alone never saw a value. + test("contextUsageEvent supplies the absolute context usage percentage", () => { + const parsed = parseKiroEvent("contextUsageEvent", enc.encode(JSON.stringify({ contextUsagePercentage: 42.5 }))); + expect(parsed).toEqual({ type: "context_usage", contextUsagePercentage: 42.5 }); + }); + test("thinking tags split across chunks are parsed as reasoning", async () => { const frames = [ eventFrame({ content: " Date: Mon, 3 Aug 2026 16:50:43 +0200 Subject: [PATCH 2/5] fix(kiro): defer the reasoning envelope until the turn's items close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught a real defect that made the round-trip a no-op in the streaming path. Kiro sends its reasoning blob at the END of a turn, while the assistant message is still open. Emitting the envelope-only item on arrival was wrong twice over: - `outputIndex` only advances when an item CLOSES, so the open message and the envelope item were emitted under the same output index. - The envelope landed BEFORE the assistant message, and the parser pairs a krc-only item backwards, so it found no preceding assistant turn and dropped the blob as orphaned — silently defeating the fix. Both paths now stash the blob and flush it after every open item has closed: after the closes in the streaming `done` case, and after the trailing flushes in the batch path. Message phase inference is untouched, so a Kiro final answer is still classified `final_answer` rather than being force-closed as commentary. The batch path also released `bytesOf(encrypted)` through `pushOutput` without ever retaining it. It now charges the blob when stashing and lets `pushOutput` release that retained allocation, so the translator budget balances. Adds tests/kiro-reasoning-roundtrip.test.ts, which bridges adapter events and re-parses the emitted items the way Codex replays history — the end-to-end coverage the original tests lacked. Three of its five cases fail against the previous commit. Scope: verified that gpt-5.6-terra and gpt-5.6-luna return `redactedContent` exactly like gpt-5.6-sol, so the whole GPT-5.6 family was affected. Handling keys off the wire field, not the model id. --- src/adapters/kiro-events.ts | 5 +- src/bridge.ts | 65 ++++++++---- structure/04_transports-and-sidecars.md | 20 ++-- tests/kiro-reasoning-roundtrip.test.ts | 130 ++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 tests/kiro-reasoning-roundtrip.test.ts diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 2e55369e6..24e8f2337 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -118,8 +118,9 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi : {}), }; case "reasoningContentEvent": - // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the model actually - // returns for Sol-family models. Both may be absent on a bare event. + // `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 diff --git a/src/bridge.ts b/src/bridge.ts index bb625f58b..cc121e2e5 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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 = ""; @@ -870,18 +893,9 @@ export function bridgeToResponsesSSE( break; } case "kiro_redacted_reasoning": { - // Opaque and unrenderable: emit an envelope-only reasoning item (no summary, no text - // deltas) so it stays invisible in the app while still round-tripping. Kiro sends it - // once the turn's content and tool calls are done, so nothing open is disturbed. - const encrypted = encodeReasoningEnvelope({ krc: event.data }); - const reservation = budget?.reserveTransient(bytesOf(encrypted), { 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 }); - reservation?.commitRetained(); - retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning"); - outputIndex++; + // 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": { @@ -1054,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 = { @@ -1376,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 = ""; @@ -1544,13 +1565,14 @@ function buildResponseJSONWithBudget( batchRedacted.push(e.data); break; case "kiro_redacted_reasoning": - // Envelope-only reasoning item, same contract as the streaming path. + // Stash only — pushed after the trailing flushes. One blob per turn, so last wins. { - const encrypted = encodeReasoningEnvelope({ krc: e.data }); - pushOutput({ - type: "reasoning", id: `rs_${uuid()}`, summary: [], encrypted_content: encrypted, - }, bytesOf(encrypted), "reasoning"); + 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"); @@ -1650,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 ( diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 22b7ac2dc..03508d921 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -383,12 +383,20 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden ## Kiro reasoning round-trip (`redactedContent`) -Kiro never returns plaintext reasoning for its Sol-family models: `reasoningContentEvent` carries a -KMS-encrypted `redactedContent` blob, and `gpt-5.6-sol`'s `additionalModelRequestFieldsSchema` -(`ListAvailableModels`) accepts only `reasoning.effort` — there is no display/summary opt-in. Kiro's -own CLI replays that blob on the matching `assistantResponseMessage.reasoningContent` to preserve -model reasoning across turns; dropping it makes every turn restart without the previous turn's -reasoning. Verified against kiro-cli 2.14.1 and 2.16.0. +Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, +`-luna`): `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, never `text`. +Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort` +with `additionalProperties: false` — there is no display/summary opt-in, so this is the only +reasoning these models can return. Kiro's own CLI replays the blob on the matching +`assistantResponseMessage.reasoningContent` to preserve model reasoning across turns; dropping it +makes every turn restart without the previous turn's reasoning. Verified on kiro-cli 2.14.1 and +2.16.0, all three models. + +The Claude 4.6+/5 entries advertise a different, richer contract (`thinking.type` adaptive/disabled, +`thinking.display` summarized/omitted, `output_config.effort`, `max_tokens`) and are not covered by +that measurement; older Claude, deepseek, minimax, glm, and qwen entries advertise no additional +fields at all. The handling below keys off the wire field, not the model id, so any model that +sends `redactedContent` round-trips. - The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the diff --git a/tests/kiro-reasoning-roundtrip.test.ts b/tests/kiro-reasoning-roundtrip.test.ts new file mode 100644 index 000000000..5ce62cf05 --- /dev/null +++ b/tests/kiro-reasoning-roundtrip.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; +import { parseRequest } from "../src/responses/parser"; +import { decodeReasoningEnvelope } from "../src/responses/reasoning-envelope"; +import type { AdapterEvent } from "../src/types"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; + +const BLOB = "LktUUn5+ZXlKbGJtTnllWEIwYVc5dVVtVm5hVzl1SWpvaQ=="; + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + return out; +} + +/** Output items in emission order, as Codex reconstructs them from the SSE stream. */ +function doneItems(sse: string): Record[] { + const items: Record[] = []; + for (const line of sse.split("\n")) { + if (!line.startsWith("data: ")) continue; + try { + const json = JSON.parse(line.slice(6)) as { type?: string; item?: Record; output_index?: number }; + if (json.type === "response.output_item.done" && json.item) { + items.push({ ...json.item, __index: json.output_index }); + } + } catch { /* partial frame */ } + } + return items; +} + +/** Feed emitted items back as Responses input, the way Codex replays history next turn. */ +function reparse(items: Record[]) { + return parseRequest({ + model: "kiro/gpt-5.6-sol", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...items.map(({ __index, status, ...item }) => item), + ], + }); +} + +// Kiro emits its reasoning blob at the END of a turn, while the assistant message is still open. +// Emitting the envelope item on arrival reused the open message's output_index AND placed the blob +// before the message, where the parser's backwards pairing drops it as orphaned — silently +// defeating the round-trip. Both paths must defer it until the message has closed. +describe("kiro redacted-reasoning round-trip (bridge → parse)", () => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "the answer" }, + { type: "kiro_redacted_reasoning", data: BLOB }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, + ]; + + test("SSE: the blob lands after the assistant message, on its own output index", async () => { + const items = doneItems(await drain(bridgeToResponsesSSE(replay(events), "kiro/gpt-5.6-sol"))); + const types = items.map(i => i.type); + expect(types).toEqual(["message", "reasoning"]); + + const indexes = items.map(i => i.__index); + expect(new Set(indexes).size).toBe(indexes.length); // no output_index collision + + const envelope = decodeReasoningEnvelope(items[1].encrypted_content as string); + expect(envelope?.krc).toBe(BLOB); + expect(items[1].summary).toEqual([]); + }); + + test("SSE: replayed history attaches the blob to the assistant turn that produced it", async () => { + const items = doneItems(await drain(bridgeToResponsesSSE(replay(events), "kiro/gpt-5.6-sol"))); + const assistant = reparse(items).context.messages.find(m => m.role === "assistant"); + expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe(BLOB); + }); + + test("batch: the blob lands after the assistant message and survives replay", () => { + const response = buildResponseJSON( + [ + { type: "text_delta", text: "the answer" }, + { type: "kiro_redacted_reasoning", data: BLOB }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, + ], + "kiro/gpt-5.6-sol", + ); + const output = response.output as Record[]; + expect(output.map(i => i.type)).toEqual(["message", "reasoning"]); + + const assistant = reparse(output).context.messages.find(m => m.role === "assistant"); + expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe(BLOB); + }); + + test("batch: the retained blob is released, leaving no translator-budget drift", () => { + const budget = createTranslatorBudget(); + buildResponseJSON( + [ + { type: "text_delta", text: "the answer" }, + { type: "kiro_redacted_reasoning", data: BLOB }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, + ], + "kiro/gpt-5.6-sol", + undefined, + undefined, + { budget }, + ); + // Only the finalized output items stay retained; the raw blob must not be double-counted or + // released without ever having been charged. + expect(budget.snapshot().currentBytes).toBeGreaterThanOrEqual(0); + expect(budget.snapshot().overflows).toBe(0); + }); + + test("a turn ending in a tool call still pairs the blob with that assistant turn", async () => { + const items = doneItems(await drain(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_1", name: "bash" }, + { type: "tool_call_delta", arguments: "{\"command\":\"ls\"}" }, + { type: "tool_call_end" }, + { type: "kiro_redacted_reasoning", data: BLOB }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, endTurn: false }, + ]), "kiro/gpt-5.6-sol"))); + expect(items.map(i => i.type)).toEqual(["function_call", "reasoning"]); + + const assistant = reparse(items).context.messages.find(m => m.role === "assistant"); + expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe(BLOB); + }); +}); From 8610630ecdc0069f1470b9dde1c9188cca743e59 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:03:32 +0200 Subject: [PATCH 3/5] test(kiro): make the budget regression actually observe the retained blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch budget assertion was vacuous twice over. It passed the budget in the wrong argument position, so `buildResponseJSON` built its own internal budget and the snapshot under test was never written to (`highWaterBytes: 0`). Even wired correctly, asserting `currentBytes >= 0` could not distinguish a leaked raw blob from the finalized items that legitimately stay retained. It now uses a 4 KB blob and asserts `currentBytes` equals EXACTLY the finalized output items' bytes — a still-retained raw blob shows up as ~4 KB of excess, and releasing bytes that were never charged shows up as a shortfall. A separate `highWaterBytes` assertion proves the blob was charged while held rather than never accounted for. Four of the file's five cases now fail against cbb5d21e. Also corrects the metadataEvent description: every capture put the context percentage in `contextUsageEvent`, but the parser still accepts a finite `contextUsagePercentage` (and `tokenUsage`) from `metadataEvent` as a fallback, so documenting it as impossible was wrong. --- src/adapters/kiro-events.ts | 5 +++-- structure/04_transports-and-sidecars.md | 13 +++++++++--- tests/kiro-reasoning-roundtrip.test.ts | 27 ++++++++++++++++--------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 24e8f2337..eab61ea51 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -18,8 +18,9 @@ const KNOWN_EVENT_TYPES = new Set([ "toolUseEvent", "messageMetadataEvent", "metadataEvent", - // Kiro reports context pressure in its OWN event type, not inside metadataEvent (verified - // against kiro-cli 2.14.1 and 2.16.0: metadataEvent carries only `stopReason`). + // 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", diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 03508d921..b6e8e6ca0 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -409,9 +409,16 @@ sends `redactedContent` round-trips. - The blob lives on `OcxAssistantMessage.kiroRedactedReasoning`, not on a thinking content part, so no other adapter replays provider-private state if the conversation switches providers. -Kiro also reports context pressure in its own `contextUsageEvent`; `metadataEvent` carries only -`stopReason`. Spend arrives in `meteringEvent` as **credits, not tokens** — there is no `tokenUsage` -on this wire at all, which is why Kiro usage stays estimated. +Kiro reports context pressure in its own `contextUsageEvent`, which is the authoritative source. On +every capture taken (2.14.1 and 2.16.0) `metadataEvent` carried only `stopReason` — which is why +reading the percentage from `metadataEvent` alone never saw a value — but the parser still accepts a +finite `contextUsagePercentage` (and a `tokenUsage` block) there as a fallback, so a value parsed +from `metadataEvent` is legitimate rather than impossible. Both feed the same field, and any +positive value overwrites an earlier one. + +Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried +`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently +ignored because a credit is not a token count. ## Parallel tool calls (default-on for chat providers) diff --git a/tests/kiro-reasoning-roundtrip.test.ts b/tests/kiro-reasoning-roundtrip.test.ts index 5ce62cf05..a38121d59 100644 --- a/tests/kiro-reasoning-roundtrip.test.ts +++ b/tests/kiro-reasoning-roundtrip.test.ts @@ -95,23 +95,30 @@ describe("kiro redacted-reasoning round-trip (bridge → parse)", () => { expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe(BLOB); }); - test("batch: the retained blob is released, leaving no translator-budget drift", () => { + test("batch: the raw blob is retained then released, leaving only the finalized items", () => { + // A big blob makes the accounting unambiguous: the raw string is an allocation distinct from + // the finalized item that embeds its base64. + const bigBlob = "X".repeat(4000); const budget = createTranslatorBudget(); - buildResponseJSON( + const response = buildResponseJSON( [ { type: "text_delta", text: "the answer" }, - { type: "kiro_redacted_reasoning", data: BLOB }, + { type: "kiro_redacted_reasoning", data: bigBlob }, { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, ], "kiro/gpt-5.6-sol", - undefined, - undefined, - { budget }, + { translatorBudget: budget }, ); - // Only the finalized output items stay retained; the raw blob must not be double-counted or - // released without ever having been charged. - expect(budget.snapshot().currentBytes).toBeGreaterThanOrEqual(0); - expect(budget.snapshot().overflows).toBe(0); + const items = response.output as Record[]; + const finalizedBytes = items.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0); + const { currentBytes, highWaterBytes, overflows } = budget.snapshot(); + + // EXACTLY the finalized output items remain retained. A raw blob still held would show up as + // ~4000 extra bytes here; releasing bytes that were never charged would show up as a shortfall. + expect(currentBytes).toBe(finalizedBytes); + // ...and it really was charged while held, rather than never accounted for at all. + expect(highWaterBytes).toBeGreaterThanOrEqual(finalizedBytes + bigBlob.length); + expect(overflows).toBe(0); }); test("a turn ending in a tool call still pairs the blob with that assistant turn", async () => { From 3a2f0574b5ca47573d9fe8ae21e2bb2d031a6aca Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:54:30 +0200 Subject: [PATCH 4/5] fix(kiro): let the authoritative context usage outrank the fallback Both `contextUsageEvent` and `metadataEvent.contextUsagePercentage` assigned the same variable with last-write-wins, so arrival order alone decided which value survived and a trailing fallback frame could clobber the authoritative one. Precedence is now by source: once a contextUsageEvent value lands, later metadataEvent percentages are ignored. The fallback still works on its own. Covered in both event orders, with the discriminating values asserted to differ first so the ordering assertions cannot pass vacuously. The test fails against the unguarded assignment. Also disposes the translator budget in the batch regression via try/finally. `createTranslatorBudget` registers in module-global aggregate accounting, so an assertion failure would have leaked retained bytes into every later test. --- src/adapters/kiro.ts | 15 +++++++-- structure/04_transports-and-sidecars.md | 5 +-- tests/kiro-reasoning-roundtrip.test.ts | 44 ++++++++++++++----------- tests/kiro-stream.test.ts | 29 ++++++++++++++++ 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 2ea1bc84d..9a8fb6b70 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -891,6 +891,10 @@ async function* parseKiroAttemptEvents( let outputChars = ""; let outputCharsBytes = 0; let contextUsagePercentage: number | undefined; + // `contextUsageEvent` is the authoritative source; `metadataEvent.contextUsagePercentage` is a + // fallback for wires that carry it there. Once an authoritative value lands, a later fallback + // must not clobber it — otherwise event order alone decides which value survives. + let contextUsageIsAuthoritative = false; let returnedConversationId = conversationId; let assistantText = ""; let assistantTextBytes = 0; @@ -1161,7 +1165,11 @@ async function* parseKiroAttemptEvents( switch (ev.type) { case "metadata": if (ev.usage) authoritativeUsage = ev.usage; - if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) { + if ( + !contextUsageIsAuthoritative + && ev.contextUsagePercentage !== undefined + && ev.contextUsagePercentage > 0 + ) { contextUsagePercentage = ev.contextUsagePercentage; } if (ev.stopReason !== undefined) stopReason = ev.stopReason; @@ -1195,7 +1203,10 @@ async function* parseKiroAttemptEvents( } break; case "context_usage": - if (ev.contextUsagePercentage > 0) contextUsagePercentage = ev.contextUsagePercentage; + if (ev.contextUsagePercentage > 0) { + contextUsagePercentage = ev.contextUsagePercentage; + contextUsageIsAuthoritative = true; + } break; case "tool": { for (const contentEvent of thinking.flush()) { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index b6e8e6ca0..a9db82e3a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -413,8 +413,9 @@ Kiro reports context pressure in its own `contextUsageEvent`, which is the autho every capture taken (2.14.1 and 2.16.0) `metadataEvent` carried only `stopReason` — which is why reading the percentage from `metadataEvent` alone never saw a value — but the parser still accepts a finite `contextUsagePercentage` (and a `tokenUsage` block) there as a fallback, so a value parsed -from `metadataEvent` is legitimate rather than impossible. Both feed the same field, and any -positive value overwrites an earlier one. +from `metadataEvent` is legitimate rather than impossible. Precedence is by SOURCE, not arrival +order: once a `contextUsageEvent` value lands, a later `metadataEvent` percentage is ignored, so a +trailing fallback frame cannot clobber the authoritative one. Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried `tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently diff --git a/tests/kiro-reasoning-roundtrip.test.ts b/tests/kiro-reasoning-roundtrip.test.ts index a38121d59..7024711c9 100644 --- a/tests/kiro-reasoning-roundtrip.test.ts +++ b/tests/kiro-reasoning-roundtrip.test.ts @@ -100,25 +100,31 @@ describe("kiro redacted-reasoning round-trip (bridge → parse)", () => { // the finalized item that embeds its base64. const bigBlob = "X".repeat(4000); const budget = createTranslatorBudget(); - const response = buildResponseJSON( - [ - { type: "text_delta", text: "the answer" }, - { type: "kiro_redacted_reasoning", data: bigBlob }, - { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, - ], - "kiro/gpt-5.6-sol", - { translatorBudget: budget }, - ); - const items = response.output as Record[]; - const finalizedBytes = items.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0); - const { currentBytes, highWaterBytes, overflows } = budget.snapshot(); - - // EXACTLY the finalized output items remain retained. A raw blob still held would show up as - // ~4000 extra bytes here; releasing bytes that were never charged would show up as a shortfall. - expect(currentBytes).toBe(finalizedBytes); - // ...and it really was charged while held, rather than never accounted for at all. - expect(highWaterBytes).toBeGreaterThanOrEqual(finalizedBytes + bigBlob.length); - expect(overflows).toBe(0); + // The budget registers in module-global aggregate accounting, so it must be disposed even if an + // assertion throws — otherwise this test leaks retained bytes into every later test. + try { + const response = buildResponseJSON( + [ + { type: "text_delta", text: "the answer" }, + { type: "kiro_redacted_reasoning", data: bigBlob }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, + ], + "kiro/gpt-5.6-sol", + { translatorBudget: budget }, + ); + const items = response.output as Record[]; + const finalizedBytes = items.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0); + const { currentBytes, highWaterBytes, overflows } = budget.snapshot(); + + // EXACTLY the finalized output items remain retained. A raw blob still held would show up as + // ~4000 extra bytes here; releasing bytes that were never charged would show up as a shortfall. + expect(currentBytes).toBe(finalizedBytes); + // ...and it really was charged while held, rather than never accounted for at all. + expect(highWaterBytes).toBeGreaterThanOrEqual(finalizedBytes + bigBlob.length); + expect(overflows).toBe(0); + } finally { + budget.dispose(); + } }); test("a turn ending in a tool call still pairs the blob with that assistant turn", async () => { diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index b2a92bdf1..1f8355a5d 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -1407,6 +1407,35 @@ describe("kiro adapter — parseStream", () => { expect(done.contextTotalTokens).toBeGreaterThan(done.inputTokens + done.outputTokens); }); + // contextUsageEvent is authoritative; metadataEvent's percentage is a fallback. Both fed the same + // field with last-write-wins, so a trailing metadataEvent could clobber the authoritative value + // and let event order alone decide. Both orders must settle on the contextUsageEvent value. + test("contextUsageEvent wins over metadataEvent in either event order", async () => { + const checkpointFor = async (...frames: Uint8Array[]) => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(4_000) }])); + const done = await doneUsage(adapter, eventFrame({ content: "answer" }), ...frames); + return done.contextTotalTokens; + }; + const authoritativeOnly = await checkpointFor(eventFrame({ contextUsagePercentage: 80 }, "contextUsageEvent")); + const fallbackOnly = await checkpointFor(eventFrame({ contextUsagePercentage: 10 }, "metadataEvent")); + // Guard the guard: 80% and 10% must be distinguishable for the ordering assertions to mean + // anything, and the fallback must still work on its own. + expect(fallbackOnly).toBeGreaterThan(0); + expect(authoritativeOnly).toBeGreaterThan(fallbackOnly!); + + const fallbackFirst = await checkpointFor( + eventFrame({ contextUsagePercentage: 10 }, "metadataEvent"), + eventFrame({ contextUsagePercentage: 80 }, "contextUsageEvent"), + ); + const authoritativeFirst = await checkpointFor( + eventFrame({ contextUsagePercentage: 80 }, "contextUsageEvent"), + eventFrame({ contextUsagePercentage: 10 }, "metadataEvent"), + ); + expect(fallbackFirst).toBe(authoritativeOnly!); + expect(authoritativeFirst).toBe(authoritativeOnly!); + }); + test("authoritative metadata token usage overrides estimates and preserves cache splits", async () => { const adapter = createKiroAdapter(provider); await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(700) }])); From c3d2998cdc67983fc571eba00cee3c8834dc468d Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:12:44 +0200 Subject: [PATCH 5/5] fix(kiro): treat a zero authoritative context percentage as a real reading `contextUsageEvent` claimed authority only when its percentage was > 0, so precedence depended on the VALUE rather than the source: a genuine 0% reading (fresh conversation) left contextUsageIsAuthoritative false and a trailing metadataEvent could then override it with a stale nonzero value. Accept >= 0 and claim authority; negatives stay rejected as malformed. Nothing downstream sees a bogus zero-token checkpoint, because contextUsageTotalFloor already discards a zero floor. The ordering test now also covers contextUsageEvent(0) followed by a nonzero metadataEvent, asserting it matches the no-percentage baseline rather than the fallback's reading. It fails against the > 0 guard. --- src/adapters/kiro.ts | 7 ++++++- tests/kiro-stream.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 9a8fb6b70..f14fda2b2 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1203,7 +1203,12 @@ async function* parseKiroAttemptEvents( } break; case "context_usage": - if (ev.contextUsagePercentage > 0) { + // Zero is a real reading (a fresh conversation), not "absent", so it must still claim + // authority — otherwise precedence would depend on the VALUE rather than the source and a + // trailing metadataEvent could override a genuine 0%. Negatives are malformed and + // rejected. contextUsageTotalFloor already discards a zero floor, so nothing downstream + // sees a bogus zero-token checkpoint. + if (ev.contextUsagePercentage >= 0) { contextUsagePercentage = ev.contextUsagePercentage; contextUsageIsAuthoritative = true; } diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 1f8355a5d..78765de82 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -1434,6 +1434,16 @@ describe("kiro adapter — parseStream", () => { ); expect(fallbackFirst).toBe(authoritativeOnly!); expect(authoritativeFirst).toBe(authoritativeOnly!); + + // 0% is a real reading (fresh conversation), so it claims authority like any other. If it were + // treated as "absent", the trailing fallback below would win and report 10% occupancy. + const baseline = await checkpointFor(); + const zeroThenFallback = await checkpointFor( + eventFrame({ contextUsagePercentage: 0 }, "contextUsageEvent"), + eventFrame({ contextUsagePercentage: 10 }, "metadataEvent"), + ); + expect(zeroThenFallback).toBe(baseline); + expect(zeroThenFallback).not.toBe(fallbackOnly); }); test("authoritative metadata token usage overrides estimates and preserves cache splits", async () => {