From 664934bf8c21b2c28100ab5a7026b3b872a714ba Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:58:04 +0200 Subject: [PATCH] feat(kiro): honor native END_TURN and Opus 5 native reasoning effort Ports the Kiro provider improvements from the codex-kiro downstream. Native end-turn: Kiro's terminal metadataEvent can carry a stopReason. An END_TURN response holding plain assistant text, with neither a real tool call nor a private completion call to arbitrate against it, now ends the turn directly with that text as the final answer. The bounded completion retry is kept for streams that omit the signal, so an ordinary tool-enabled turn stops paying for a second inference request. Because the stop reason only arrives at the end of the stream, required mode holds staged assistant events until either a real tool call starts (released as commentary) or the stream ends (released as the final answer on END_TURN, otherwise as commentary). Each held event yields a heartbeat in its place so the bridge's stall watchdog stays armed, and anything still held when the stream fails is released before the terminal event. This trades token-by-token rendering of a tool-enabled turn's answer for removing the extra request that the same turn previously always paid. Opus 5 reasoning effort: Kiro accepts effort for Claude models through the output_config.effort request field rather than the Sol-only reasoning.effort field, so claude-opus-5 now sends its selected level natively instead of falling back to emulated thinking instructions. Restatement suppression: the duplicate-answer check compared the bounded retry against the preceding commentary with whitespace-normalized exact equality, so any rewording rendered the same answer twice. It now compares by shared in-order word sequence, ignoring whitespace, punctuation, and case, gated on a growth bound and a longest-inserted-run bound so a retry that repeats the commentary and then appends real new content is still kept. Co-Authored-By: Claude Opus 5 --- .../src/content/docs/reference/adapters.md | 40 +++-- src/adapters/kiro-events.ts | 4 +- src/adapters/kiro-restatement.ts | 141 ++++++++++++++++++ src/adapters/kiro.ts | 117 +++++++++++++-- src/providers/kiro-models.ts | 5 +- structure/04_transports-and-sidecars.md | 9 +- tests/kiro-adapter.test.ts | 20 +++ tests/kiro-restatement.test.ts | 129 ++++++++++++++++ tests/kiro-stream.test.ts | 110 ++++++++++++++ 9 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 src/adapters/kiro-restatement.ts create mode 100644 tests/kiro-restatement.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index dfdadd4e27..73d318bbc7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -101,24 +101,36 @@ streams the response back **untranslated**. ### Completion semantics -Kiro text events do not carry a dependable end-turn phase. When an ordinary client tool is present, -opencodex therefore adds a private `codex_kiro_final_answer` tool to the upstream request. Progress -text streams as commentary and cannot terminate the turn. The adapter consumes the private call, -emits its answer as final text, and never exposes the private tool to Codex or Claude Code. -When the web-search sidecar is active, this commentary still streams immediately; only the events -needed to decide whether the model requested a synthetic search remain buffered. - -If Kiro emits progress without calling the completion tool, the adapter makes one continuation. That -single retry may finish with a validated private completion or plain final text. It cannot recurse: -an empty or reasoning-only retry is returned as retryable incomplete, while a real client tool call -keeps the turn open. If the retry only repeats the preceding commentary after whitespace -normalization, the duplicate output is suppressed while the turn still completes. Tool-free +Kiro assistant text carries no dependable end-turn phase of its own. Its terminal `metadataEvent` +can, however, carry a native `stopReason`. An `END_TURN` response holding plain assistant text with +no client tool call ends the turn directly, with that text emitted as the final answer and no extra +model round trip. + +When the stop reason is absent or is anything other than `END_TURN`, the compatibility path applies. +If an ordinary client tool is present, opencodex adds a private `codex_kiro_final_answer` tool to +the upstream request; progress text streams as commentary and cannot terminate the turn. The adapter +consumes the private call, emits its answer as final text, and never exposes the private tool to +Codex or Claude Code. Because the stop reason only arrives at the end of the stream, assistant text +in a tool-enabled turn is held until either a real tool call starts (released as commentary) or the +stream ends (released as the final answer on `END_TURN`, otherwise as commentary). When the +web-search sidecar is active, released commentary still streams ahead of the terminal event; only +the events needed to decide whether the model requested a synthetic search remain buffered. + +If Kiro emits progress without an `END_TURN` stop reason and without calling the completion tool, +the adapter makes one continuation. That single retry may finish with a validated private completion +or plain final text. It cannot recurse: an empty or reasoning-only retry is returned as retryable +incomplete, while a real client tool call keeps the turn open. If the retry only restates the +preceding commentary, the duplicate output is suppressed while the turn still completes. +Restatement is judged by shared in-order word sequence, ignoring whitespace, punctuation, and case, +so a reworded repeat is caught while a retry that appends real new content is kept. Tool-free requests retain normal text completion behavior. ### Reasoning effort -`gpt-5.6-sol` has verified native effort support. Its selected `low`, `medium`, `high`, `xhigh`, or -`max` value is sent as `additionalModelRequestFields.reasoning.effort`. Other Kiro models currently +`gpt-5.6-sol` and `claude-opus-5` have verified native effort support, and each model family names +the request field differently. A selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent +as `additionalModelRequestFields.reasoning.effort` for `gpt-5.6-sol` and as +`additionalModelRequestFields.output_config.effort` for `claude-opus-5`. Other Kiro models currently use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in the user content because their native effort field has not been verified. Do not interpret an advertised effort control on those models as proof of upstream-native reasoning support. diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 555b07490f..835862c0b5 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -6,7 +6,7 @@ export type ParsedKiroEvent = | { type: "reasoning"; data?: string } | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean } | { type: "truncation"; data: string } - | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number } + | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string } | { type: "message_metadata"; conversationId?: string } | { type: "invalid_state"; message?: string } | { type: "error"; reason?: string; message?: string }; @@ -141,12 +141,14 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi ) { return malformed(eventType, "contextUsagePercentage must be a finite number"); } + const stopReason = optionalString(eventType, parsed, "stopReason"); return { type: "metadata", ...(parseTokenUsage(eventType, parsed.tokenUsage) !== undefined ? { usage: parseTokenUsage(eventType, parsed.tokenUsage) } : {}), ...(typeof contextUsagePercentage === "number" ? { contextUsagePercentage } : {}), + ...(stopReason !== undefined ? { stopReason } : {}), }; } case "invalidStateEvent": diff --git a/src/adapters/kiro-restatement.ts b/src/adapters/kiro-restatement.ts new file mode 100644 index 0000000000..351fb28740 --- /dev/null +++ b/src/adapters/kiro-restatement.ts @@ -0,0 +1,141 @@ +// Near-duplicate detection for Kiro's bounded completion retry. +// +// Kiro sometimes answers as ordinary assistant text instead of calling the reserved completion +// tool. opencodex then issues one bounded continuation retry, and a noncompliant model often +// answers that retry by saying what it already said. Rendering both copies shows the user the same +// answer twice. +// +// Observed restatements rewrite freely: they swap phrases, reorder clauses, and repunctuate while +// preserving the content. Exact comparison therefore misses them. Two measured signals separate a +// restatement from a genuine answer: +// +// 1. how much of the longer text the two share as an in-order word sequence, which is high for a +// rewording and low for unrelated text; +// 2. the largest block of consecutive new words the retry introduces, which stays at phrase length +// for a rewording and reaches sentence length when the retry actually adds information. +// +// Requiring both keeps a retry that repeats the earlier commentary and then appends real new +// detail, which a similarity threshold alone would discard. +// +// The comparison is deliberately conservative. Suppressing a genuine answer loses information, +// while failing to suppress a duplicate is cosmetic. + +/** + * Minimum word count, required on both sides, before inexact matching applies. Individual words + * carry the meaning of short texts, where `found` versus `fixed` inverts the message, so those must + * match word for word. + */ +const MIN_INEXACT_MATCH_WORDS = 40; + +/** + * Percentage of the longer text that both texts must share as an in-order word sequence for the + * retry to count as a restatement. Across 58 adjacent commentary/final-answer pairs on record, + * observed restatements measured 74%, 76%, 81%, 84%, and 92%, while the next pair below those + * measured 39%. The outcome over that corpus is identical for any value from 50 through 70, because + * the inserted-run and growth bounds do the remaining separation, so this sits mid-plateau rather + * than on a knife edge. + */ +const RESTATEMENT_MATCH_PERCENT = 65; + +/** + * Longest run of consecutive new words a restatement may introduce. Measured rewordings inserted at + * most five consecutive words, whereas a retry that adds real information contributes at least a + * clause. + */ +const MAX_INSERTED_WORD_RUN = 11; + +/** + * Upper bound on the words compared from each side. Reconstructing the shared sequence needs a table + * proportional to the product of the two lengths, so this caps the work and the allocation. A + * restatement is already evident from its opening few hundred words. + */ +const MAX_COMPARE_WORDS = 400; + +/** + * Percentage by which the retry may exceed the preceding commentary before it is treated as new + * content rather than a rewording. A retry that is markedly longer is adding information even when + * it opens with a repeat. + */ +const MAX_GROWTH_PERCENT = 120; + +/** Reports whether `candidate` merely restates `previous` rather than adding material content. */ +export function isKiroRestatement(previous: string, candidate: string): boolean { + const previousWords = comparableWords(previous); + const candidateWords = comparableWords(candidate); + if (previousWords.length === candidateWords.length && previousWords.every((word, i) => word === candidateWords[i])) { + return true; + } + if (previousWords.length < MIN_INEXACT_MATCH_WORDS || candidateWords.length < MIN_INEXACT_MATCH_WORDS) { + return false; + } + if (candidateWords.length * 100 > previousWords.length * MAX_GROWTH_PERCENT) return false; + const left = previousWords.slice(0, MAX_COMPARE_WORDS); + const right = candidateWords.slice(0, MAX_COMPARE_WORDS); + const shared = sharedWordSequence(left, right); + const longer = Math.max(left.length, right.length); + return shared.length * 100 >= longer * RESTATEMENT_MATCH_PERCENT + && longestInsertedRun(right, shared) <= MAX_INSERTED_WORD_RUN; +} + +/** + * Splits `text` into lowercase words with surrounding punctuation removed so that rewrapped, + * repunctuated, and recapitalized restatements still align. + */ +function comparableWords(text: string): string[] { + return text + .split(/\s+/) + .map(word => word.replace(/^[^\p{L}\p{N}]+/u, "").replace(/[^\p{L}\p{N}]+$/u, "").toLowerCase()) + .filter(word => word.length > 0); +} + +/** + * Returns the longest common subsequence of the two word lists, which is the text they share in + * order while tolerating insertions, substitutions, and deletions. + */ +function sharedWordSequence(previous: string[], candidate: string[]): string[] { + const width = candidate.length + 1; + const lengths = new Uint16Array((previous.length + 1) * width); + for (let i = 0; i < previous.length; i++) { + for (let j = 0; j < candidate.length; j++) { + lengths[(i + 1) * width + j + 1] = previous[i] === candidate[j] + ? lengths[i * width + j] + 1 + : Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]); + } + } + const shared: string[] = []; + let i = previous.length; + let j = candidate.length; + while (i > 0 && j > 0) { + if (previous[i - 1] === candidate[j - 1]) { + shared.push(previous[i - 1]); + i--; + j--; + } else if (lengths[(i - 1) * width + j] >= lengths[i * width + j - 1]) { + i--; + } else { + j--; + } + } + shared.reverse(); + return shared; +} + +/** + * Returns the longest run of consecutive `candidate` words that are absent from `shared`, which + * measures the largest single block of new text the candidate introduces. + */ +function longestInsertedRun(candidate: string[], shared: string[]): number { + let sharedIndex = 0; + let longest = 0; + let current = 0; + for (const word of candidate) { + if (sharedIndex < shared.length && shared[sharedIndex] === word) { + sharedIndex++; + current = 0; + } else { + current++; + longest = Math.max(longest, current); + } + } + return longest; +} diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 9e43e891c0..67b8642644 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -13,6 +13,7 @@ import { safeKiroHttpErrorMessage, type KiroErrorClassification, } from "./kiro-errors"; +import { isKiroRestatement } from "./kiro-restatement"; import { KiroThinkingParser } from "./kiro-thinking"; import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation"; import { createKiroToolNameRegistry, fallbackToolUseId, fingerprint, invocationId, isValidKiroConversationId, mapModelId, normalizeToolId, osTag, stableConversationId } from "./kiro-wire"; @@ -227,8 +228,22 @@ function kiroRuntimeEndpoint(provider: OcxProviderConfig, region: string): strin export type KiroReasoningMode = "native" | "emulated"; +// Kiro takes a verified native effort field for these models, and each model family names it +// differently: the Sol-only `reasoning.effort` versus the Claude-specific `output_config.effort`. +// Models absent from this table fall back to emulated thinking instructions. +const KIRO_NATIVE_EFFORT_FIELDS: Record = { + "gpt-5.6-sol": "reasoning", + "claude-opus-5": "output_config", +}; + +const KIRO_NATIVE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + +function kiroNativeEffortField(modelId: string): "reasoning" | "output_config" | undefined { + return KIRO_NATIVE_EFFORT_FIELDS[normalizeKiroModelId(modelId)]; +} + export function kiroReasoningMode(modelId: string): KiroReasoningMode { - return normalizeKiroModelId(modelId) === "gpt-5.6-sol" ? "native" : "emulated"; + return kiroNativeEffortField(modelId) ? "native" : "emulated"; } function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined { @@ -482,11 +497,12 @@ export function buildKiroPayload( }, }; const effort = parsed.options.reasoning; - if (kiroReasoningMode(parsed.modelId) === "native" && effort && effort !== "none") { - if (!["low", "medium", "high", "xhigh", "max"].includes(effort)) { - throw new Error(`Kiro gpt-5.6-sol does not support reasoning effort ${JSON.stringify(effort)}`); + const effortField = kiroNativeEffortField(parsed.modelId); + if (effortField && effort && effort !== "none") { + if (!KIRO_NATIVE_EFFORTS.includes(effort)) { + throw new Error(`Kiro ${normalizeKiroModelId(parsed.modelId)} does not support reasoning effort ${JSON.stringify(effort)}`); } - payload.additionalModelRequestFields = { reasoning: { effort } }; + payload.additionalModelRequestFields = { [effortField]: { effort } }; } if (profileArn) payload.profileArn = profileArn; return { payload, nameMap, conversationId, completionMode }; @@ -583,14 +599,16 @@ function retryableKiroIncomplete( }; } -function normalizedKiroAnswer(text: string): string { - return text.trim().replace(/\s+/g, " "); -} - function isRepeatedKiroAnswer(text: string, previous?: string): boolean { - return normalizedKiroAnswer(text) === normalizedKiroAnswer(previous ?? ""); + return isKiroRestatement(previous ?? "", text); } +/** + * Kiro's native stop reason for a turn the model considers finished. Only this value is + * authoritative; `TOOL_USE` and an absent reason both leave the turn incomplete. + */ +const KIRO_END_TURN_STOP_REASON = "END_TURN"; + async function* parseKiroAttempt( response: Response, mode: KiroCompletionMode, @@ -601,6 +619,44 @@ async function* parseKiroAttempt( conversationId: string | undefined, previousAssistantText?: string, contextInputEstimate?: number, +): AsyncGenerator { + // `required` mode holds staged commentary here so a terminal END_TURN can relabel it as the final + // answer instead of paying for another inference request. Anything the inner parser leaves behind + // — every early error return — is flushed before the terminal event so partial output is never + // silently dropped. + const deferred: AdapterEvent[] = []; + const attempt = parseKiroAttemptEvents( + response, + mode, + modelId, + inputTokens, + contextWindowState, + nameMap, + conversationId, + deferred, + previousAssistantText, + contextInputEstimate, + ); + let next = await attempt.next(); + while (!next.done) { + yield next.value; + next = await attempt.next(); + } + for (const event of deferred.splice(0)) yield event; + return next.value; +} + +async function* parseKiroAttemptEvents( + response: Response, + mode: KiroCompletionMode, + modelId: string | undefined, + inputTokens: number, + contextWindowState: KiroContextWindowState, + nameMap: Map | undefined, + conversationId: string | undefined, + deferred: AdapterEvent[], + previousAssistantText?: string, + contextInputEstimate?: number, ): AsyncGenerator { const emptyResult = (): KiroAttemptResult => ({ assistantText: "", sawReasoning: false }); if (!response.body) { @@ -621,6 +677,7 @@ async function* parseKiroAttempt( let completionAnswer: string | undefined; let completionCalls = 0; let authoritativeUsage: OcxUsage | undefined; + let stopReason: string | undefined; const fallbackEvents: AdapterEvent[] = []; const thinking = new KiroThinkingParser(); @@ -711,6 +768,17 @@ async function* parseKiroAttempt( return terminal ? { terminal } : { tool: next }; }; + // In `required` mode Kiro's stop reason only arrives on the terminal metadata event, so staged + // commentary is held until either a real tool call proves the turn continues (flush as + // commentary) or the stream ends (relabel as the final answer when END_TURN says so). A heartbeat + // stands in for each held event so the bridge's stall watchdog stays armed. + const defer = (event: AdapterEvent): AdapterEvent[] => { + if (sawRealTool) return [...deferred.splice(0), event]; + if (event.type !== "text_delta" && deferred.length === 0) return [event]; + deferred.push(event); + return [{ type: "heartbeat" }]; + }; + const stage = (event: AdapterEvent): AdapterEvent[] => { if (event.type === "text_delta") { assistantText += event.text; @@ -723,7 +791,7 @@ async function* parseKiroAttempt( fallbackEvents.push(phased); return []; } - return [phased]; + return mode === "required" ? defer(phased) : [phased]; } if (event.type === "reasoning_raw_delta" || event.type === "thinking_delta") { const text = event.type === "reasoning_raw_delta" ? event.text : event.thinking; @@ -734,7 +802,7 @@ async function* parseKiroAttempt( fallbackEvents.push(event); return []; } - return [event]; + return mode === "required" ? defer(event) : [event]; }; const parseCompletion = (chunks: string[]): string | Error => { @@ -822,6 +890,7 @@ async function* parseKiroAttempt( if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) { contextUsagePercentage = ev.contextUsagePercentage; } + if (ev.stopReason !== undefined) stopReason = ev.stopReason; break; case "message_metadata": if (isValidKiroConversationId(ev.conversationId)) returnedConversationId = ev.conversationId; @@ -933,15 +1002,39 @@ async function* parseKiroAttempt( ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}), }); } + // Kiro's own end-of-turn verdict, trusted only when it is unambiguous: plain assistant text, + // with neither a real tool call nor a private completion call to arbitrate against it. + const nativeEndTurn = stopReason === KIRO_END_TURN_STOP_REASON + && sawText + && !sawRealTool + && completionAnswer === undefined + && completionCalls === 0; + debugProviderDiagnostic("kiro", "attempt_complete", { mode, sawText, sawReasoning, sawRealTool, completionCalls, + nativeEndTurn, + ...(stopReason !== undefined ? { stopReason } : {}), assistantChars: assistantText.length, }); + if (mode === "required") { + if (nativeEndTurn) { + for (const event of deferred.splice(0)) { + yield event.type === "text_delta" ? { ...event, phase: "final_answer" } : event; + } + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + for (const event of deferred.splice(0)) yield event; + } + if (mode === "text_fallback") { if (completionAnswer !== undefined) { for (const event of fallbackEvents) yield event; diff --git a/src/providers/kiro-models.ts b/src/providers/kiro-models.ts index ab6b72c1ab..72063fde9b 100644 --- a/src/providers/kiro-models.ts +++ b/src/providers/kiro-models.ts @@ -47,8 +47,9 @@ export const KIRO_MODEL_CONTEXT_WINDOWS: Record = { const KIRO_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -// gpt-5.6-sol sends these values through Kiro's verified native reasoning field. Other models map -// them to bounded thinking instructions until their native effort support is verified. +// gpt-5.6-sol and claude-opus-5 send these values through Kiro's verified native effort fields +// (`reasoning.effort` and `output_config.effort` respectively). Other models map them to bounded +// thinking instructions until their native effort support is verified. export const KIRO_MODEL_REASONING_EFFORTS: Record = Object.fromEntries( KIRO_MODELS.map(id => [id, KIRO_REASONING_EFFORTS]), ); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 84b93db57e..d691da78f8 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -109,7 +109,14 @@ The web-search loop requests `stream: true` for every routed-model iteration, bu needed to decide whether to intercept a synthetic search call. Text explicitly phased as `commentary` is safe to forward live because it cannot terminate the turn; this keeps Kiro's progress visible. A Kiro stream EOF after user-facing text or reasoning gets one bounded completion -retry, because the upstream text event does not distinguish progress from a final answer. Synthetic search calls, real tool calls, +retry, because the upstream text event does not distinguish progress from a final answer — unless +the terminal `metadataEvent` carries the native `stopReason: "END_TURN"`, which is authoritative and +ends the turn with that text as the final answer. Since the stop reason arrives only at the end of +the stream, `required`-mode assistant text is held inside the adapter until a real tool call starts +(released as `commentary`) or the stream ends (released as `final_answer` on `END_TURN`, otherwise +as `commentary`). Each held event yields a `heartbeat` in its place so the stall watchdog stays +armed. This trades token-by-token rendering of a tool-enabled turn's answer for removing the extra +inference request that the same turn previously always paid. Synthetic search calls, real tool calls, and terminal events remain buffered until the iteration validates. Only the first iteration's final response headers/status and any 429 key rotations are handled eagerly. A failure before downstream SSE starts returns non-2xx JSON; once headers have started the final response, a generation failure diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index d8a38f235a..49f9667900 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -709,6 +709,26 @@ describe("kiro adapter — native and emulated reasoning effort", () => { expect(emulatedBody.additionalModelRequestFields).toBeUndefined(); expect(emulatedBody.conversationState.currentMessage.userInputMessage.content).toContain("800"); }); + + test("claude-opus-5 sends native effort through the Claude-specific output_config field", async () => { + const body = JSON.parse((await createKiroAdapter(provider).buildRequest({ + ...parsedWith([{ role: "user", content: "solve" }], undefined, "claude-opus-5"), + options: { reasoning: "max", maxOutputTokens: 1000 }, + })).body); + + expect(body.additionalModelRequestFields).toEqual({ output_config: { effort: "max" } }); + // Native effort replaces the emulated thinking-tag prompt entirely. + expect(body.conversationState.currentMessage.userInputMessage.content).toBe("solve"); + }); + + test("native-effort models reject efforts Kiro does not accept", async () => { + for (const modelId of ["gpt-5.6-sol", "claude-opus-5"]) { + await expect(createKiroAdapter(provider).buildRequest({ + ...parsedWith([{ role: "user", content: "solve" }], undefined, modelId), + options: { reasoning: "minimal" }, + })).rejects.toThrow(`Kiro ${modelId} does not support reasoning effort "minimal"`); + } + }); }); describe("kiro adapter — per-model context windows (kiro.dev/docs/models)", () => { diff --git a/tests/kiro-restatement.test.ts b/tests/kiro-restatement.test.ts new file mode 100644 index 0000000000..763c0c00b5 --- /dev/null +++ b/tests/kiro-restatement.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { isKiroRestatement } from "../src/adapters/kiro-restatement"; + +// Commentary/retry pairs modeled on the observed duplicate-answer sessions: the retry reworks +// spans of the commentary while adding nothing. The three pairs get progressively heavier +// rewording, so together they exercise the whole band the thresholds have to cover. +const COMMENTARY = "I'm the opencodex proxy talking to Kiro on your behalf. Think of me as the " + + "translation layer that turns Codex Responses requests into Kiro conversation state, decodes " + + "the event stream back, and keeps the turn honest rather than guessing when the model is done.\n\n" + + "Right now I'm pointed at the runtime endpoint for your credential's region, so the region, " + + "profile ARN, and refreshed access token all come from the Kiro credential store on this " + + "machine and never from the request. Tool names are normalized on the way out and restored on " + + "the way back, and the private completion tool never reaches your client.\n\nWhat should we look at?"; + +const RETRY = "I'm the opencodex proxy talking to Kiro on your behalf. Think of me as the " + + "translation layer that turns Codex Responses requests into Kiro conversation state, decodes " + + "the event stream back, and keeps the turn honest rather than assuming when the model has " + + "finished.\n\nRight now I'm pointed at the runtime endpoint for your credential's region, so " + + "the region, profile ARN, and refreshed access token are all read from the Kiro credential " + + "store on this machine and never from the request. Tool names get normalized on the way out " + + "and restored on the way back, and the private completion tool never reaches your client.\n\n" + + "What should we look at?"; + +const HEAVIER_COMMENTARY = "I'm the opencodex proxy — the local process that lets Codex and Claude " + + "Code speak to providers they were never built for.\n\nPractically speaking: I parse the " + + "incoming request, pick an adapter, rewrite the payload for that provider's wire format, and " + + "stream the response back as Responses events. I can fan a turn out to a web-search sidecar " + + "when the request asks for it, and I carry per-provider retry policy so a reset connection or " + + "an expired token does not surface as a failed turn.\n\nThere's a wrinkle with Kiro " + + "specifically: its assistant text carries no dependable end-of-turn marker, so ordinary text " + + "is treated as commentary and a private completion tool arbitrates the end of the turn. That " + + "tool is consumed here and never shown to your client.\n\nWhat would you like to work on?"; + +const HEAVIER_RETRY = "I'm the opencodex proxy, the local process that lets Codex and Claude Code " + + "talk to providers they were never built for.\n\nPractically speaking: I parse the incoming " + + "request, choose an adapter, rewrite the payload for that provider's wire format, and stream " + + "the response back as Responses events. I can fan a turn out to a web-search sidecar when the " + + "request asks for one, and I carry per-provider retry policy so a reset connection or an " + + "expired token never surfaces as a failed turn.\n\nThere's a wrinkle with Kiro in particular: " + + "its assistant text has no dependable end-of-turn marker, so ordinary text counts as " + + "commentary and a private completion tool decides the end of the turn. That tool is consumed " + + "here and never reaches your client.\n\nWhat would you like to work on?"; + +const RESTRUCTURED_COMMENTARY = "I'm the opencodex proxy running in front of Kiro. I'm a " + + "translation layer: I can rewrite Codex requests for the Kiro wire, decode its event stream, " + + "map tools in both directions, and carry a turn through to a real answer rather than stopping " + + "at the first plausible stream end.\n\nRight now I'm pointed at the Kiro runtime endpoint for " + + "your credential's region, so I'm operating under its constraints: no parallel tool calls, no " + + "structured output, no service tiers, and a context window that has to be tracked locally " + + "because usage is not always reported.\n\nWhat would you like to work on?"; + +const RESTRUCTURED_RETRY = "I'm the opencodex proxy, a translation layer sitting in front of Kiro. " + + "Instead of stopping at the first plausible stream end, I can rewrite Codex requests onto the " + + "Kiro wire, decode the event stream it returns, map tools in both directions, and drive a turn " + + "through to a genuine answer.\n\nAt the moment I'm aimed at the Kiro runtime endpoint for the " + + "region on your credential, so its documented limits apply: no parallel tool calls, no " + + "structured output, no service tiers, and a context window I have to track locally because " + + "token usage is not always reported.\n\nWhat would you like to work on?"; + +describe("kiro restatement detection", () => { + test("detects a reworded restatement of the preceding commentary", () => { + expect(isKiroRestatement(COMMENTARY, RETRY)).toBe(true); + }); + + test("detects a heavily reworded restatement", () => { + expect(isKiroRestatement(HEAVIER_COMMENTARY, HEAVIER_RETRY)).toBe(true); + }); + + test("detects a restatement that also restructures its opening", () => { + expect(isKiroRestatement(RESTRUCTURED_COMMENTARY, RESTRUCTURED_RETRY)).toBe(true); + }); + + test("detects an exact repeat ignoring whitespace", () => { + expect(isKiroRestatement("Task complete. Files updated.", " Task complete.\nFiles updated. ")).toBe(true); + }); + + test("detects a repunctuated and recapitalized repeat", () => { + const previous = "Refactored the parser into a dedicated module, moved its tests alongside the " + + "implementation, reran the affected suite, and confirmed the formatter and targeted lint " + + "both stay clean after the change."; + const candidate = "Refactored the parser into a dedicated module; moved its tests alongside the " + + "implementation; reran the affected suite; and confirmed the formatter and targeted lint " + + "both stay clean after the change!"; + expect(isKiroRestatement(previous, candidate)).toBe(true); + }); + + test("keeps short texts that differ by a single word", () => { + expect(isKiroRestatement("Found the bug in kiro.ts", "Fixed the bug in kiro.ts")).toBe(false); + }); + + test("keeps a distinct answer after a progress update", () => { + expect(isKiroRestatement( + "I'll check the tests now.", + "All 42 tests pass. The regression came from a stale snapshot.", + )).toBe(false); + }); + + test("keeps a long retry that appends material content", () => { + const candidate = `${COMMENTARY}\n\nOne thing worth flagging before you start: the bundled ` + + "context window for this model is smaller than the catalog advertises, the completion tool " + + "is injected only when ordinary tools are present, and the bounded retry runs at most once " + + "per turn so a model that never completes will surface as a retryable incomplete result."; + expect(isKiroRestatement(COMMENTARY, candidate)).toBe(false); + }); + + test("keeps a long retry that repeats the opening then adds a new section", () => { + const candidate = `${COMMENTARY}\n\nBefore you start, three things are worth knowing about the ` + + "current state of this proxy and the provider it is routing to."; + expect(isKiroRestatement(COMMENTARY, candidate)).toBe(false); + }); + + test("keeps long answers that share only boilerplate phrasing", () => { + const previous = "I'm going to start by reading the request translation in the Kiro adapter, " + + "then the event stream decoder, and then the provider catalog, so that I can see how the " + + "completion mode is chosen and where the bounded retry is issued before I change any " + + "behavior at all in this adapter."; + const candidate = "The root cause is that the duplicate check compares the retry against the " + + "previous assistant text with exact equality, so a reworded restatement is treated as a " + + "distinct answer and both copies reach the transcript, which is exactly what the two " + + "assistant items in this session show."; + expect(isKiroRestatement(previous, candidate)).toBe(false); + }); + + test("comparison is bounded for very long texts", () => { + const long = "alpha beta gamma delta epsilon ".repeat(400); + expect(isKiroRestatement(long, long)).toBe(true); + expect(isKiroRestatement(long, `${long}zeta `.repeat(2))).toBe(false); + }); +}); diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index ef52c532a0..6afdde0d3f 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -110,6 +110,16 @@ describe("kiro adapter — parseStream", () => { }); }); + test("Kiro event parser surfaces the native stop reason and rejects a non-string one", async () => { + expect(parseKiroEvent("metadataEvent", enc.encode(JSON.stringify({ stopReason: "END_TURN" })))).toEqual({ + type: "metadata", + stopReason: "END_TURN", + }); + expect(() => parseKiroEvent("metadataEvent", enc.encode(JSON.stringify({ stopReason: 7 })))).toThrow( + "invalid Kiro metadataEvent payload: stopReason must be a string", + ); + }); + test("unknown event types are ignored without parsing their payload", async () => { const unknown = encodeMessage( { ":message-type": "event", ":event-type": "futureEvent" }, @@ -438,6 +448,106 @@ describe("kiro adapter — parseStream", () => { } }); + test("native END_TURN metadata finishes a tool-enabled turn without a second request", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + return new Response(streamOf(eventFrame({ content: "should never run" }))); + }) as typeof fetch; + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "The file has " }), + eventFrame({ content: "three lines." }), + eventFrame({ stopReason: "END_TURN" }, "metadataEvent"), + )))); + + expect(fetches).toBe(0); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "The file has ", phase: "final_answer" }, + { type: "text_delta", text: "three lines.", phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + + test("a non-END_TURN stop reason still requires the bounded completion fallback", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + return new Response(streamOf(...completionFrames("Really done."))); + }) as typeof fetch; + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "Still working." }), + eventFrame({ stopReason: "TOOL_USE" }, "metadataEvent"), + )))); + + expect(fetches).toBe(1); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Still working.", phase: "commentary" }, + { type: "text_delta", text: "Really done.", phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + + test("held commentary is released as commentary the moment a real tool call starts", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const ordered: string[] = []; + for await (const event of adapter.parseStream(new Response(streamOf( + eventFrame({ content: "Let me look." }), + eventFrame({ name: "bash", toolUseId: "t1" }), + eventFrame({ input: '{"command":"pwd"}', name: "bash", toolUseId: "t1" }), + eventFrame({ name: "bash", stop: true, toolUseId: "t1" }), + eventFrame({ stopReason: "END_TURN" }, "metadataEvent"), + )))) { + if (event.type === "text_delta") ordered.push(`text:${event.phase}`); + else if (event.type !== "heartbeat") ordered.push(event.type); + } + + // END_TURN alongside a real tool call is not authoritative: the tool result must come back. + expect(ordered).toEqual(["text:commentary", "tool_call_start", "tool_call_delta", "tool_call_end", "done"]); + }); + + test("END_TURN does not promote a private completion answer's commentary", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "Checking the result." }), + ...completionFrames("Task complete."), + eventFrame({ stopReason: "END_TURN" }, "metadataEvent"), + )))); + + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Checking the result.", phase: "commentary" }, + { type: "text_delta", text: "Task complete.", phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + + test("held commentary is still delivered when the stream fails before its terminal event", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "Partial progress." }), + encodeMessage( + { ":message-type": "exception", ":exception-type": "ThrottlingException" }, + enc.encode(JSON.stringify({ message: "Too many requests." })), + ), + )))); + + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Partial progress.", phase: "commentary" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "error", status: 429, retryable: true }); + }); + test("normal Responses cancellation aborts the adapter-owned fallback without another replay", async () => { const abort = new AbortController(); let fetches = 0;