diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 0def314cc..eab61ea51 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,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", ]); @@ -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 { @@ -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": diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 3e2440045..f14fda2b2 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 } } : {}), }, } : { @@ -884,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; @@ -1154,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; @@ -1183,6 +1198,20 @@ 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": + // 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; + } break; case "tool": { for (const contentEvent of thinking.flush()) { diff --git a/src/bridge.ts b/src/bridge.ts index 21fa85118..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 = ""; @@ -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( @@ -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 = { @@ -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 = ""; @@ -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(); @@ -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 ( 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..a9db82e3a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -381,6 +381,46 @@ 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 **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 + 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 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. 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 +ignored because a credit is not a token count. + ## 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-reasoning-roundtrip.test.ts b/tests/kiro-reasoning-roundtrip.test.ts new file mode 100644 index 000000000..7024711c9 --- /dev/null +++ b/tests/kiro-reasoning-roundtrip.test.ts @@ -0,0 +1,143 @@ +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 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(); + // 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 () => { + 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); + }); +}); diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 4de16927f..78765de82 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: " { 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!); + + // 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 () => { const adapter = createKiroAdapter(provider); await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(700) }]));