From 5f70855e4c25aba5aa191eed6d92f5f6468a0245 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:48:39 +0900 Subject: [PATCH 1/3] fix(kiro): preserve code-mode task continuity Adapt upstream empty-exec output and tool-catalog retention fixes while preserving fork replay/terminal behavior. Normalize only paired code-mode results after adjacent grouping; reserve exec without bypassing count or byte budgets. Add regression coverage and English/Korean troubleshooting guidance. --- .../000_findings.md | 70 +++++++ .../src/content/docs/guides/providers.md | 2 + .../src/content/docs/ko/guides/providers.md | 2 + .../troubleshooting/kiro-task-repetition.md | 36 ++++ .../troubleshooting/kiro-task-repetition.md | 36 ++++ src/adapters/exec-tool-result-normalize.ts | 81 +++++++ src/adapters/kiro-tools.ts | 57 +++-- src/adapters/kiro.ts | 28 ++- src/adapters/tool-catalog-nudge.ts | 23 +- structure/04_transports-and-sidecars.md | 23 ++ tests/kiro-task-continuity.test.ts | 198 ++++++++++++++++++ 11 files changed, 535 insertions(+), 21 deletions(-) create mode 100644 devlog/_plan/260912_kiro_task_continuity/000_findings.md create mode 100644 docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md create mode 100644 docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md create mode 100644 src/adapters/exec-tool-result-normalize.ts create mode 100644 tests/kiro-task-continuity.test.ts diff --git a/devlog/_plan/260912_kiro_task_continuity/000_findings.md b/devlog/_plan/260912_kiro_task_continuity/000_findings.md new file mode 100644 index 0000000000..4e9645cbc6 --- /dev/null +++ b/devlog/_plan/260912_kiro_task_continuity/000_findings.md @@ -0,0 +1,70 @@ +# Kiro task repetition: missing code-mode contracts + +Date: 2026-09-12 (Asia/Seoul). + +## Scope and baseline + +Work is against the user fork `coseung2/opencodex`, based on `main` at `6aec2590b` +(`2.8.0-cs.20`). Its actual GitHub parent is `lidge-jun/opencodex`; upstream `dev` was +inspected at `7a0513c2f`. This is a scoped adaptation, not an upstream merge. Existing fork +commentary/image retirement and terminal behavior must survive. + +## Upstream comparison + +| Evidence | Existing fork status | Decision | +| --- | --- | --- | +| Upstream #2819, merged 2026-08-28, `d7a82a8fc42632760750a160c9811543b18bd76d` | Delivered-final local terminal and phase-absent hash record were present (`03c87a698`); empty exec repair and proactive echo contract were absent. | Adapt only the missing code-mode behavior. | +| #2475, merged 2026-08-25, `09062014ed4ff9ff2e200b8ce8970a4e22a4f4a1` | Catalog retained a declaration-order prefix, even when discovered tools arrived later. | Prioritize loaded search results when over budget. | +| #2750, merged 2026-08-27, `eeb774026d077cec6c91217c1f097afa92856ead` | No code-mode discovery contract or execution-path reservation. | Reserve bare freeform exec and derive the contract from the emitted catalog. | +| Host-error follow-up `163378050d75bda4bc6eb0821da9382e5a80c67a` | No host-specific recovery hint. | Add one idempotent hint for leading error context, preserving original output and status. | +| #2835 / #3012 / #3031 | Superseded prose, terminal completion, and blocking-question completion already present (`e2b2d453c`, `4ca112156`, `0055bdb7a`). | Preserve; do not reapply or weaken. | +| #3750 / issues #3734 and #3731 | Original-id adjacent result grouping already present (`7445490e2`). | Normalize after that grouping; never merge across a user/assistant barrier. | +| #543 / reasoning round-trip | Mid-turn user steering and redacted reasoning pairing already covered. | Preserve existing tests and ownership. | + +## Findings and confidence + +The fork sent `The tool completed without textual output.` for a successful freeform exec +cell that omitted `text()`/`notify()`. It also passed nonblank-but-empty wrapper text through. +No pre-call instruction explained why a bare await produces no visible result. Upstream #2819 +reports models interpreting this as lost context and restarting completed work. The missing +wire contract is reproduced here, but the reported live session was not captured, so this is +not proof that every instance of the user's repetition has that cause. + +A second deterministic failure was independent of generation: with 48 filler declarations, +exec or tools loaded by search disappeared from the outbound catalog. A model could repeatedly +search for, or avoid, the execution path it actually needed. Under the new policy, the original +order is preserved below budget; over budget, discoveries outrank filler and exec has a reserved +slot and bytes. Unlike a blind reservation, a single oversized exec fails rather than bypassing +the 96,000-byte limit. + +Known host errors can similarly encourage repeated invalid calls. Only verified code-mode calls +receive a recovery hint. Structured tools named exec, unrelated MCP namespaces, successful output +quoting a diagnostic, error status, images (including retired replay images), and nonempty +notification order remain protected by tests. The proxy does not execute or retry any command. + +## Validation + +- Wrote failing payload regressions before implementation. Empty-output guidance and catalog + preservation failed against the baseline; fixtures for optional tools/images were corrected + before the final run. +- Focused Kiro adapter, stream, reasoning round-trip, public-server completion, catalog-nudge, + and new continuity coverage: **216 pass, 0 fail, 783 assertions** across six files. +- `bun run typecheck`: passed with the pinned Bun 1.4.0 installation. +- Frozen root dependency installation: passed without lockfile changes. +- Documentation frozen install/build: passed, 231 pages. English and Korean troubleshooting + pages explain the behavior and its limits. +- Initial repository-wide run: **7,309 pass, 11 skip, 8 fail (including 5 module-load errors)** + across 508 files. This was not a green full-suite run. +- A module-load-only pass isolated all five loading errors to missing React dependencies in the + new worktree's GUI package. Frozen GUI dependency installation changed no lockfile. The five + affected suites plus repository hygiene then passed: **96 pass, 0 fail, 366 assertions**. +- The remaining three `shutdown-launcher` cases (SIGINT/SIGTERM/SIGHUP) were rerun on a separate, + unmodified worktree at baseline `6aec2590b`: **0 pass, 3 fail**, all at the startup health check + (`tests/shutdown-launcher.test.ts:94`), before signal handling. No unrelated launcher source fix + is included. The full suite was not repeated after dependency installation. +- Staged `bun run privacy:scan` and `git diff --check`: passed. No dependency/lockfile changes. + +No real Kiro account request, credential mutation, automatic deployment, or operational restart +was performed. A read-only health check reported the operational service as `2.8.0-cs.18`; +that version string alone does not establish which selectively copied modules are loaded. +The source correction and deployment status are deliberately separate. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 16d9d04b61..4d99013ec2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -154,6 +154,8 @@ and WARN rows that include a recovery Action. When an OAuth provider account nee ### Kiro credential import +For repeated work or apparently missing tool output, see [Kiro repeats completed work](/troubleshooting/kiro-task-repetition/). This is separate from account login. + Kiro login expects the Kiro CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then sign in with `kiro-cli login`. Without a `kiro-cli` session, `ocx login kiro` falls diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 34271eee1b..174f508d3b 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -106,6 +106,8 @@ Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃 ### Kiro 자격 증명 가져오기 +작업을 반복하거나 도구 출력이 사라진 것처럼 보이면 [Kiro가 완료한 작업을 반복할 때](/ko/troubleshooting/kiro-task-repetition/)를 참고하세요. 계정 로그인과는 별도의 문제입니다. + Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL https://cli.kiro.dev/install | bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1' | iex`로 설치한 뒤 먼저 `kiro-cli login`으로 로그인하세요. `kiro-cli` 세션이 없으면 `ocx login kiro`는 붙여 넣은 액세스 토큰이나 `KIRO_ACCESS_TOKEN` 환경 변수로 폴백합니다. 일반 `ocx login kiro` 가져오기는 CLI SQLite 데이터베이스를 읽기 전용으로 열며 데이터베이스, WAL, SHM을 수정하지 않습니다. diff --git a/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md b/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md new file mode 100644 index 0000000000..bfd557de32 --- /dev/null +++ b/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md @@ -0,0 +1,36 @@ +--- +title: Kiro가 완료한 작업을 반복할 때 +description: 코드 모드의 출력 누락과 실제 대화 상태 손실을 구분하고 반복 도구 호출을 진단합니다. +--- + +작업을 반복한다는 사실만으로 Kiro의 대화 문맥이 사라졌다고 판단할 수는 없습니다. Codex 코드 모드에서 바깥쪽 `exec` 도구는 JavaScript를 실행하며, 단순한 `await`나 마지막 식의 반환값은 자동으로 출력되지 않습니다. 명령이 실행됐어도 모델에는 빈 도구 결과가 보일 수 있습니다. + +## 코드 모드의 빈 출력 + +다음 단계에서 필요한 반환값은 같은 셀에서 출력합니다. + +```js +text(JSON.stringify(await tools.exec_command({ cmd: "ls" }))); +``` + +출력이 비었다는 이유만으로 파일 수정·배포 등 부작용이 있는 작업을 다시 실행하지 말고 기존 상태를 먼저 확인해야 합니다. 명령이 `session_id`를 반환했다면 새로 실행하는 대신 해당 세션을 조회합니다. + +OpenCodex는 실제 코드 모드 도구 목록을 확인한 뒤 실행 전 지침과 Kiro에 전달하는 빈 결과 설명을 제공합니다. 동일한 원래 호출의 인접 출력은 합친 뒤 설명을 한 번만 추가합니다. 실패 래퍼나 오류 결과는 실패로 유지하고, 이미지 결과는 출력 누락으로 취급하지 않습니다. 무관한 MCP 도구나 이름만 `exec`인 일반 구조화 도구에는 JavaScript 실행 환경 지침을 넣지 않습니다. + +`tools.apply_patch`에 문자열 대신 객체를 전달하거나 모듈을 import하는 등 알려진 호스트 오류에는 짧은 복구 안내를 덧붙입니다. 원래 오류 내용과 상태는 유지하며, 정상 출력에서 오류 문구를 인용한 경우에는 변경하지 않습니다. + +## 도구 목록이 큰 경우 + +Kiro에 보내는 클라이언트 도구 목록에는 48개·직렬화 크기 96,000바이트 한도가 있으며, 비공개 완료 도구를 위한 여유는 별도로 둡니다. 한도를 초과하면 도구 검색 결과를 일반 선언보다 우선하고, 중첩 도구를 실행하는 freeform `exec`의 공간을 예약합니다. 이후 검색 진입점과 나머지 도구를 가능한 범위에서 포함합니다. 생략 안내는 실제 전송 목록을 기준으로 작성합니다. `exec` 하나가 바이트 한도보다 크면 제한을 무시하지 않고 명시적으로 오류를 반환합니다. + +한도 이내에서는 선언 순서를 유지합니다. 네임스페이스가 있는 MCP 셸 도구는 코드 모드를 비활성화하지 않습니다. 이 동작을 위한 추가 설정이나 계정 재인증은 필요하지 않습니다. + +## 작업 종료와 대화 상태 + +기존 Kiro 비공개 완료 채널은 그대로 턴을 종료합니다. 진행 메시지는 최종 답변이 아니며, 이미 전달한 최종 답변 때문에 닫힌 작업을 다시 열어서는 안 됩니다. 실제 사용자 후속 질문은 정상적으로 처리합니다. 이 수정은 도구 이력을 버리거나, 추론 상태 재전송을 바꾸거나, 인자가 같다는 이유만으로 도구 호출을 삭제하거나, 명령을 자동 재실행하지 않습니다. + +회귀 테스트는 빈 결과 설명 누락과 도구 목록 탈락을 재현하지만 모든 모델 반복 현상의 원인이 같다는 뜻은 아닙니다. 수정 빌드 배포 후에도 반복된다면 직전 결과가 빈 출력인지, 오류인지, 정상 결과인지, 이미 전달한 최종 답변인지 구분해야 합니다. 실행 중인 버전과 민감 정보를 제거한 요청 식별자를 기록하되 자격 증명이나 원문 비공개 프롬프트를 공개하지 않습니다. + +## 원본 저장소 참고 + +이번 포크 적용은 [#2819: 빈 exec 출력과 완료 답변 재개 방지](https://github.com/lidge-jun/opencodex/pull/2819), [#2475: 도구 검색 결과 우선 보존](https://github.com/lidge-jun/opencodex/pull/2475), [#2750: 코드 모드 탐색 및 실행 경로 예약](https://github.com/lidge-jun/opencodex/pull/2750)을 참고했습니다. 이미 반영된 완료 관련 수정에는 [#3012](https://github.com/lidge-jun/opencodex/pull/3012), [#3031](https://github.com/lidge-jun/opencodex/pull/3031), 인접 출력의 소유권 보존 수정 [#3750](https://github.com/lidge-jun/opencodex/pull/3750)이 있습니다. diff --git a/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md b/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md new file mode 100644 index 0000000000..933e9c4581 --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md @@ -0,0 +1,36 @@ +--- +title: Kiro repeats completed work +description: Distinguish missing code-mode output from lost conversation state and diagnose repeated tool calls. +--- + +Repeated work is not by itself evidence that Kiro lost the conversation. In Codex code mode, the outer `exec` tool evaluates JavaScript, and a bare `await` or final expression does not print its result. The model can therefore see an empty tool response even though the command ran successfully. + +## Empty code-mode output + +When a value is needed for the next step, print it in the same cell: + +```js +text(JSON.stringify(await tools.exec_command({ cmd: "ls" }))); +``` + +Do not rerun an edit, deployment, or other side-effecting operation solely because the output was empty. Inspect existing state first. If a command returns a `session_id`, poll that session rather than start the command again. + +OpenCodex supplies this contract before a verified code-mode call and explains empty output on its Kiro continuation. It combines adjacent outputs from the same original call before adding one explanation. A failed wrapper or an error result remains a failure; image output is not treated as a missing print. Unrelated MCP tools and structured tools merely named `exec` do not receive JavaScript-isolate instructions. + +Known code-mode host errors, such as passing an object instead of a string to `tools.apply_patch` or importing a module, receive a short recovery hint. The original error text and status are retained. Successful output quoting an error message is left unchanged. + +## Large tool catalogs + +Kiro's client-tool catalog is bounded to 48 tools and 96,000 serialized bytes, with separate headroom for the private completion tool. When that budget is exceeded, tools returned by tool search take priority over ordinary declarations. OpenCodex reserves space for the freeform `exec` execution path, then the search gateway and remaining tools as space permits. The omission notice describes the actual emitted catalog. A single `exec` specification that cannot fit the byte budget fails explicitly instead of bypassing the limit. + +Below the budget, declaration order is unchanged. Namespaced MCP shell tools do not disable the code-mode contract. No setting or account reauthentication is needed for this behavior. + +## Completion and conversation state + +Kiro's existing private completion channel still ends the turn. Progress text is not a final answer, and a delivered final answer must not reopen the task. Real user follow-ups continue normally. This correction does not discard tool history, change reasoning-blob replay, remove repeated calls solely because their arguments match, or introduce automatic command retries. + +The adapter tests reproduce missing-output guidance and catalog eviction deterministically; they do not prove that every model-generated repetition has the same cause. If repetition persists after deploying the corrected build, distinguish whether the preceding output was empty, an error, a successful result, or an already-delivered final answer. Record the running version and sanitized request identifiers, not credentials or raw private prompts. + +## Upstream references + +The fork adaptation is based on [#2819: empty exec output and final-answer reopening](https://github.com/lidge-jun/opencodex/pull/2819), [#2475: tool-search result priority](https://github.com/lidge-jun/opencodex/pull/2475), and [#2750: code-mode discovery and execution-path reservation](https://github.com/lidge-jun/opencodex/pull/2750). The already-present completion fixes include [#3012](https://github.com/lidge-jun/opencodex/pull/3012), [#3031](https://github.com/lidge-jun/opencodex/pull/3031), and the adjacent-result ownership fix [#3750](https://github.com/lidge-jun/opencodex/pull/3750). diff --git a/src/adapters/exec-tool-result-normalize.ts b/src/adapters/exec-tool-result-normalize.ts new file mode 100644 index 0000000000..a1758fe432 --- /dev/null +++ b/src/adapters/exec-tool-result-normalize.ts @@ -0,0 +1,81 @@ +/** + * Code-mode contract adapted from lidge-jun/opencodex #2819 and its host-error follow-up. + * Callers must establish ownership from the actual emitted freeform exec catalog and the paired + * assistant call, never from a result's self-reported tool name. This module does not execute tools. + */ +export const CODE_MODE_RESULT_ECHO_SENTENCE = + "Nothing in the isolate is echoed automatically: a bare await or final expression is discarded. Pass values you need to read to text(...) or notify(...), for example text(JSON.stringify(await tools.exec_command({cmd: 'ls'}))). Empty output is not lost context. Do not repeat completed work merely to recover an unprinted value; inspect existing state before retrying a side-effecting call."; + +export const CODE_MODE_HOST_CONTRACT_SENTENCE = + "Nested tools.apply_patch(patch) takes one string, not an object; use bare *** Begin Patch and *** End Patch marker lines without code fences or extra asterisks. The isolate has no import, require, or module loader. If tools.exec_command returns a session_id, poll with tools.write_stdin on later calls rather than restarting the command."; + +const EMPTY_EXEC_OUTPUT_MESSAGE = + "[empty output: the exec cell completed but emitted nothing. This is not lost context and not a blocked tool. In code mode, pass any value you need to see to text(...) or notify(...); a bare await or final expression is not echoed. Do not repeat completed or side-effecting work just because its return value was not printed. Inspect existing state before deciding whether another call is necessary.]"; +const FAILED_EXEC_OUTPUT_MESSAGE = + "[exec failed with no captured output: this is a real failure, not an empty success. Inspect the call for a thrown error or syntax problem and check existing state before retrying; an earlier side effect may already have happened.]"; + +/** + * Small, forward-only wrapper parser. No overlapping whitespace regex quantifiers: long tool + * output must remain linear. Payload after , including a second marker, is never erased. + */ +function emptyWrapperKind(text: string): "success" | "failure" | undefined { + const trimmed = text.trim(); + if (!trimmed) return "success"; + let index = 0; + let failed = false; + const firstEnd = trimmed.indexOf("\n"); + const firstLine = (firstEnd < 0 ? trimmed : trimmed.slice(0, firstEnd)).trimEnd(); + if (/^(?:Script completed|Command finished|Execution finished|Script failed)(?:\b)/.test(firstLine)) { + failed = firstLine.startsWith("Script failed"); + index = firstEnd < 0 ? trimmed.length : firstEnd + 1; + } + const skipWhitespace = (): void => { + while (index < trimmed.length && trimmed[index]!.trim() === "") index++; + }; + skipWhitespace(); + if (trimmed.startsWith("Wall time", index)) { + const end = trimmed.indexOf("\n", index); + index = end < 0 ? trimmed.length : end + 1; + } + skipWhitespace(); + if (trimmed.startsWith("Output:", index)) index += "Output:".length; + skipWhitespace(); + if (trimmed.startsWith("", index)) index += "".length; + skipWhitespace(); + return index === trimmed.length ? (failed ? "failure" : "success") : undefined; +} + +const RECOVERY_PREFIX = "[recovery: "; +// A successful source read may quote every one of these strings. Only leading error context +// establishes a host failure, and the original text/status must survive the annotation. +const HOST_ERROR_PREFIX = /^(?:Script failed(?:[ \t]*(?:\r?\n|$)|:)|Script error:|(?:Error|TypeError|SyntaxError):|tool `apply_patch` expects a string input\b|apply_patch verification failed:|Unsupported import in exec:)/i; +const HOST_FAILURE_GUIDANCE: ReadonlyArray = [ + ["expects a string input", "tools.apply_patch takes one string argument; pass the patch text itself, not an object."], + ["the first line of the patch must be", "Start the patch with the bare *** Begin Patch marker line; remove code fences, prose and extra asterisks."], + ["the last line of the patch must be", "End the patch with the bare *** End Patch marker line; remove trailing prose and extra asterisks."], + ["unsupported import in exec", "Imports are unavailable here; use the injected tools, text, notify and ALL_TOOLS globals."], +]; + +/** Normalize only after adjacent outputs for one verified call have been collected. */ +export function normalizeCodeModeToolResult( + texts: readonly string[], + options: { isError: boolean; hasImages: boolean }, +): string[] | undefined { + const kinds = texts.map(emptyWrapperKind); + if (!options.hasImages && kinds.every(kind => kind !== undefined)) { + return [options.isError || kinds.includes("failure") ? FAILED_EXEC_OUTPUT_MESSAGE : EMPTY_EXEC_OUTPUT_MESSAGE]; + } + // Add at most one recovery line for this call. Replayed annotations are idempotent. + if (texts.some(text => text.includes(RECOVERY_PREFIX))) return undefined; + for (let index = 0; index < texts.length; index++) { + const text = texts[index]!; + if (!HOST_ERROR_PREFIX.test(text.trimStart())) continue; + const lower = text.toLowerCase(); + const hit = HOST_FAILURE_GUIDANCE.find(([marker]) => lower.includes(marker)); + if (!hit) continue; + const annotated = [...texts]; + annotated[index] = `${text}\n${RECOVERY_PREFIX}${hit[1]}]`; + return annotated; + } + return undefined; +} diff --git a/src/adapters/kiro-tools.ts b/src/adapters/kiro-tools.ts index c25e55ae05..3038a858f4 100644 --- a/src/adapters/kiro-tools.ts +++ b/src/adapters/kiro-tools.ts @@ -2,6 +2,7 @@ import type { OcxParsedRequest, OcxTool } from "../types"; import { namespacedToolName } from "../types"; import { normalizeKiroModelId } from "../providers/kiro-models"; import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire"; +import { isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge"; const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024; const MAX_KIRO_TOOL_DESCRIPTION_GPT_56_SOL = 9_216; @@ -172,18 +173,23 @@ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], reg return `[opencodex] Kiro's outbound catalog budget allows ${kept} of ${kept + omitted.length} client tools this turn. Omitted and unavailable this turn: ${summary}.`; } +function boundedCatalogPriority(tool: OcxTool): number { + if (tool.loadedFromToolSearch) return 0; + if (isCodexCodeModeExecTool(tool)) return 1; + if (tool.toolSearch) return 2; + return 3; +} + export function convertKiroToolContext( parsed: OcxParsedRequest, registry: KiroToolNameRegistry = createKiroToolNameRegistry(), -): { tools: unknown[]; systemAdditions: string[]; nameMap: Map; registry: KiroToolNameRegistry } { +): { tools: unknown[]; systemAdditions: string[]; nameMap: Map; registry: KiroToolNameRegistry; codeModeExecName?: string } { const tools = parsed.context.tools ?? []; const descriptionLimit = toolDescriptionLimit(parsed.modelId); // Validate every listed name even when tool_choice:none emulates a tool-free turn. for (const tool of tools) registry.alias(namespacedToolName(tool.namespace, tool.name)); const effectiveTools = parsed.options.toolChoice === "none" ? [] : tools; - const convertedTools: unknown[] = []; - let omittedAt = effectiveTools.length; - for (const [index, tool] of effectiveTools.entries()) { + const entries = effectiveTools.map((tool, index) => { const description = tool.description || `Tool: ${tool.name}`; // Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes // it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex @@ -198,24 +204,43 @@ export function convertKiroToolContext( inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(tool.parameters ?? {})) }, }, }; - // Preserve declaration order and only omit a suffix. Ranking tools would make a catalog change - // silently alter which capability disappears; this deterministic policy is paired with a - // model-visible omission notice so unavailable tools are explicit rather than assumed absent. - if ( - convertedTools.length >= MAX_KIRO_TOOL_COUNT - || serializedToolCatalogBytes([...convertedTools, converted]) > MAX_KIRO_TOOL_CATALOG_BYTES - ) { - omittedAt = index; - break; - } - convertedTools.push(converted); + return { tool, index, converted }; + }); + const exceedsBudget = entries.length > MAX_KIRO_TOOL_COUNT + || serializedToolCatalogBytes(entries.map(entry => entry.converted)) > MAX_KIRO_TOOL_CATALOG_BYTES; + const candidates = exceedsBudget + ? entries.toSorted((a, b) => boundedCatalogPriority(a.tool) - boundedCatalogPriority(b.tool) || a.index - b.index) + : entries; + // Upstream #2475/#2750: loaded tools outrank filler, but reserve the execution path even when + // loaded tools alone occupy every slot. Without exec their nested helpers become unreachable. + const reserved = candidates.find(entry => isCodexCodeModeExecTool(entry.tool)); + if (reserved && serializedToolCatalogBytes([reserved.converted]) > MAX_KIRO_TOOL_CATALOG_BYTES) { + throw new Error("Kiro code-mode exec exceeds the outbound tool catalog byte budget"); + } + const admitted = new Set(); + const filled: unknown[] = []; + for (const entry of candidates) { + if (entry === reserved) continue; + const projected = reserved ? [...filled, entry.converted, reserved.converted] : [...filled, entry.converted]; + if (projected.length > MAX_KIRO_TOOL_COUNT || serializedToolCatalogBytes(projected) > MAX_KIRO_TOOL_CATALOG_BYTES) break; + filled.push(entry.converted); + admitted.add(entry.index); } - const omittedTools = effectiveTools.slice(omittedAt); + if (reserved) admitted.add(reserved.index); + const emitted = candidates.filter(entry => admitted.has(entry.index)); + const convertedTools = emitted.map(entry => entry.converted); + const omittedTools = candidates.filter(entry => !admitted.has(entry.index)).map(entry => entry.tool); + // Detect the catalog the model actually receives, not a shell bridge omitted by its budget. + const emittedExec = emitted.find(entry => isCodexCodeModeExecTool(entry.tool)); + const codeModeExecName = emittedExec && !emitted.some(entry => isBareShellBridgeTool(entry.tool)) + ? emittedExec.converted.toolSpecification.name + : undefined; return { tools: convertedTools, systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [], nameMap: registry.nameMap, registry, + ...(codeModeExecName ? { codeModeExecName } : {}), }; } diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index b97cd6a5dd..a7de06aa9e 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -43,6 +43,7 @@ import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-i import { sniffImageDimensions } from "./anthropic-image-guard"; import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry"; import { convertKiroToolContext } from "./kiro-tools"; +import { normalizeCodeModeToolResult } from "./exec-tool-result-normalize"; import { neutralizeIdentity } from "./identity"; import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge"; import { @@ -514,7 +515,11 @@ export function buildKiroPayload( const boundedAddition = boundedInjectedInstruction(addition, injectedChars); if (boundedAddition) systemParts.push(boundedAddition); } - const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(kiroToolWireNames(kiroTools)); + const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames( + kiroToolWireNames(kiroTools), + undefined, + toolContext.codeModeExecName, + ); const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined; if (boundedNudge) systemParts.push(boundedNudge); if (completionMode !== "disabled") { @@ -571,8 +576,20 @@ export function buildKiroPayload( texts: string[]; count: number; hasImages: boolean; + codeModeExec: boolean; } | undefined; const finishAdjacentResult = (): void => { + if (adjacentResult?.codeModeExec) { + const normalized = normalizeCodeModeToolResult(adjacentResult.texts, { + isError: adjacentResult.result.status === "error", + hasImages: adjacentResult.hasImages, + }); + if (normalized) { + adjacentResult.result.content = normalized.map(text => ({ text })); + adjacentResult = undefined; + return; + } + } if (adjacentResult && adjacentResult.count > 1) { if (adjacentResult.texts.some(text => text.trim())) { adjacentResult.result.content = adjacentResult.texts.map(text => ({ text })); @@ -647,6 +664,9 @@ export function buildKiroPayload( const text = userContentText(tr.content); const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE; const images = isReplayedMessage ? [] : extractKiroImages(tr.content); + // Retired image bytes still prove the tool produced output; do not diagnose them as a + // missing text() call merely because the fork omits old pixels from continuation history. + const hasImages = typeof tr.content !== "string" && tr.content.some(part => part.type === "image"); const toolUseId = normalizeToolId(tr.toolCallId); const call = priorCalls.get(toolUseId); if (!call || call.rawId !== tr.toolCallId) { @@ -659,7 +679,7 @@ export function buildKiroPayload( && last.toolResults.at(-1) === adjacentResult.result ) { adjacentResult.count += 1; - adjacentResult.hasImages ||= images.length > 0; + adjacentResult.hasImages ||= hasImages; if (text.length > 0) adjacentResult.texts.push(text); last.images.push(...images); if (tr.isError) adjacentResult.result.status = "error"; @@ -681,7 +701,9 @@ export function buildKiroPayload( result, texts: text.length > 0 ? [text] : [], count: 1, - hasImages: images.length > 0, + hasImages, + // Ownership comes from the paired call and the emitted freeform catalog, never tr.toolName. + codeModeExec: toolContext.codeModeExecName !== undefined && call.wireName === "exec", }; } } diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 9de6d65f4b..92e6650028 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -8,6 +8,17 @@ import { type OcxProviderConfig, } from "../types"; +import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_RESULT_ECHO_SENTENCE } from "./exec-tool-result-normalize"; + +/** A name alone cannot distinguish a JavaScript isolate from an ordinary shell tool. */ +export function isCodexCodeModeExecTool(tool: Pick): boolean { + return !tool.namespace && tool.name === "exec" && tool.freeform === true; +} + +export function isBareShellBridgeTool(tool: Pick): boolean { + return !tool.namespace && (tool.name === "exec_command" || tool.name === "shell_command"); +} + const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS", "apply_patch"] as const; function quoteNames(names: readonly string[]): string { @@ -40,17 +51,25 @@ export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick string = name => name, + codeModeExecName?: string, +): string | undefined { const names = uniqueNames(wireNames ?? []); if (names.length === 0) return undefined; const advertised = new Set(names); - const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertised.has(name)); + const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertised.has(name) && !advertised.has(toWireName(name))); + const verifiedCodeMode = codeModeExecName && advertised.has(codeModeExecName); return [ "Tool contract: use the current tool catalog as ground truth.", `Valid tool names for this turn are exactly ${quoteNames(names)}.`, "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.", + verifiedCodeMode + ? `\`${codeModeExecName}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. The listed names are the top-level call surface, not the list of nested helpers. Call nested helpers inside exec as await tools.(...). Deferred helpers remain callable even when absent from the top-level catalog or a truncated description; discover them via the isolate global ALL_TOOLS, not tools.ALL_TOOLS. ${CODE_MODE_RESULT_ECHO_SENTENCE} ${CODE_MODE_HOST_CONTRACT_SENTENCE}` + : undefined, unavailableNeighborNames.length > 0 ? `Do not use neighboring-agent tool names ${quoteNames(unavailableNeighborNames)} unless this turn's catalog lists those exact names.` : undefined, diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 31732c6935..baef48b43c 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -521,6 +521,29 @@ allowed for a blocking question when only the user can supply the missing decisi clarification, so Kiro does not write the question as commentary and then invent its own answer to keep the work loop moving. +### Kiro code-mode continuity + +The bounded catalog preserves tool-search discoveries ahead of ordinary declarations and reserves +space for Codex's bare freeform `exec`, including when discovered tools fill all 48 slots. The +reservation participates in the 96,000-byte budget; an oversized exec fails explicitly. Under-budget +catalog order is unchanged. Code-mode guidance is derived from emitted tool objects (freeform exec +without a bare shell bridge), not from tool names alone or a result's self-reported name. + +Kiro supplies the nested-helper discovery and explicit text/notify echo contract before execution. +After adjacent outputs have been grouped by original call identity, empty code-mode results receive +one missing-output explanation. Errors, nonempty output order, and current or retired image evidence +are preserved. Known host failures gain an idempotent recovery hint only in leading error context. +No tool is executed or retried by this normalization. Commentary/image retirement, encrypted reasoning +pairing, private completion, and local delivered-answer termination remain independent. + +[Decision Log] +- 목적과 의도: Stop missing code-mode output and catalog eviction from looking like lost task state. +- 기존 구현 및 제약 조건: The fork had upstream final-answer termination but not the empty-exec repair or execution-path reservation; Kiro requires valid tool/result pairing and bounded catalogs. +- 검토한 주요 대안: Replay every command, remove repeated calls by argument equality, merge all upstream adapter changes, or adapt the missing contracts only. +- 선택한 방식: Port the scoped behavior from upstream #2819/#2475/#2750 and host-error follow-ups; normalize after original-id grouping, gate by emitted freeform ownership, and retain every existing terminal/replay guard. +- 다른 대안 대신 이 방식을 선택한 이유: Repeated calls can be legitimate and replaying a side effect is unsafe. A name alone does not establish JavaScript semantics, and an execution-path reservation cannot bypass transport limits. +- 장점, 단점 및 영향: The model receives actionable output and retains its execution path without new settings. Deterministic adapter regressions establish the missing contracts, not a guarantee that every live model loop has this cause. + ## Kiro reasoning round-trip (`redactedContent`) Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, diff --git a/tests/kiro-task-continuity.test.ts b/tests/kiro-task-continuity.test.ts new file mode 100644 index 0000000000..11f4b2ee84 --- /dev/null +++ b/tests/kiro-task-continuity.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "bun:test"; +import { buildKiroPayload } from "../src/adapters/kiro"; +import { KIRO_COMPLETION_TOOL_NAME, KIRO_EMPTY_TOOL_RESULT_MESSAGE } from "../src/adapters/kiro-constants"; +import { convertKiroToolContext, MAX_KIRO_TOOL_CATALOG_BYTES, MAX_KIRO_TOOL_COUNT } from "../src/adapters/kiro-tools"; +import { parseRequest } from "../src/responses/parser"; +import type { OcxMessage, OcxParsedRequest, OcxTool } from "../src/types"; + +const exec: OcxTool = { name: "exec", freeform: true, description: "Execute JavaScript", parameters: { type: "object", properties: { input: { type: "string" } } } }; +const filler = (count: number): OcxTool[] => Array.from({ length: count }, (_, index) => ({ name: `read_${index}`, description: "Read data", parameters: { type: "object" } })); +function request(tools: OcxTool[] = [exec], outputs: string[] = [""], overrides: { isError?: boolean; namespace?: string; image?: boolean } = {}): OcxParsedRequest { + const messages: OcxMessage[] = [ + { role: "user", content: "Continue from the tool result; do not restart completed work." }, + { role: "assistant", content: [{ type: "toolCall", id: "call_exec", name: "exec", namespace: overrides.namespace, arguments: { input: "await tools.exec_command({cmd: 'ls'})" } }], model: "claude-sonnet-4.5", timestamp: 0 }, + ...outputs.map(content => ({ role: "toolResult" as const, toolCallId: "call_exec", toolName: "exec", content, isError: overrides.isError ?? false })), + ]; + if (overrides.image) { + messages.push({ role: "toolResult", toolCallId: "call_exec", toolName: "exec", content: [{ type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }], isError: false }); + } + return { modelId: "claude-sonnet-4.5", stream: true, options: {}, context: { messages, tools } } as OcxParsedRequest; +} +function wire(parsed: OcxParsedRequest) { + const built = buildKiroPayload(parsed, undefined); + const state = built.payload.conversationState as { + history: Array<{ userInputMessage?: { content: string } }>; + currentMessage: { userInputMessage: { content: string; images?: unknown[]; userInputMessageContext: { tools?: Array<{ toolSpecification: { name: string } }>; toolResults: Array<{ toolUseId: string; status: string; content: Array<{ text: string }> }> } } }; + }; + return { + built, state, + instructions: state.history.find(entry => entry.userInputMessage)?.userInputMessage?.content ?? "", + results: state.currentMessage.userInputMessage.userInputMessageContext.toolResults, + names: (state.currentMessage.userInputMessage.userInputMessageContext.tools ?? []).map(tool => tool.toolSpecification.name), + }; +} +function names(tools: unknown[]): string[] { + return (tools as Array<{ toolSpecification: { name: string } }>).map(tool => tool.toolSpecification.name); +} + +describe("Kiro task continuity: code-mode contract and tool results", () => { + test("states the echo and nested discovery rules before execution", () => { + const result = wire(request()); + expect(result.instructions).toContain("JavaScript"); + expect(result.instructions).toContain("ALL_TOOLS"); + expect(result.instructions).toContain("text(...)"); + expect(result.instructions).toContain("not lost context"); + expect(result.instructions).toContain("returns no tool result"); + expect(result.names).toContain(KIRO_COMPLETION_TOOL_NAME); + }); + + test.each(["", " ", "Script completed\nWall time: 0.01s\nOutput:\n", "Command finished\r\nWall time: 0.01s\r\nOutput:\r\n"])("explains empty exec output without claiming context loss: %j", output => { + const result = wire(request([exec], [output])); + expect(result.results[0].content[0].text).toContain("not lost context"); + expect(result.results[0].content[0].text).toContain("text(...)"); + expect(result.results[0].content[0].text).toContain("Do not repeat"); + expect(result.results[0].status).toBe("success"); + }); + + test("coalesces multiple empty notifications before adding one explanation", () => { + const result = wire(request([exec], ["", "Script completed\nOutput:\n", " "])); + expect(result.results).toHaveLength(1); + expect(result.results[0].content).toHaveLength(1); + expect(result.results[0].content[0].text).toContain("not lost context"); + }); + + test("preserves nonempty progress and final output in source order", () => { + const result = wire(request([exec], ["", "first notification", "final value", " "])); + expect(result.results[0].content.map(part => part.text)).toEqual(["first notification", "final value", " "]); + }); + + test.each(["Script failed\nWall time: 0.01s\nOutput:\n", "Script failed\r\n\r\nWall time: 0.01s\r\nOutput:\r\n "])("does not describe failed empty wrappers as success: %j", output => { + expect(wire(request([exec], [output])).results[0].content[0].text).toContain("real failure"); + }); + + test("an error in any adjacent empty result remains a failure", () => { + const parsed = request([exec], ["", ""]); + (parsed.context.messages.at(-1) as { isError: boolean }).isError = true; + const result = wire(parsed).results[0]; + expect(result.status).toBe("error"); + expect(result.content[0].text).toContain("real failure"); + expect(result.content[0].text).not.toContain("not a blocked tool"); + }); + + test("image results, including retired replay images, are not missing output", () => { + for (const replayed of [false, true]) { + const parsed = request([exec], [""], { image: true }); + if (replayed) parsed._replayMessagePrefixLen = parsed.context.messages.length; + expect(wire(parsed).results[0].content[0].text).toBe(KIRO_EMPTY_TOOL_RESULT_MESSAGE); + } + }); + + test.each([ + [{ ...exec, freeform: false }], + [exec, { name: "exec_command", parameters: { type: "object" } }], + ])("does not assume code-mode semantics for a flat catalog: %j", (...tools) => { + const result = wire(request(tools as OcxTool[])); + expect(result.instructions).not.toContain("Nothing in the isolate"); + expect(result.results[0].content[0].text).toBe(KIRO_EMPTY_TOOL_RESULT_MESSAGE); + }); + + test("an unrelated MCP exec cannot acquire guidance through a spoofed result name", () => { + const foreign = { ...exec, namespace: "mcp__foreign" }; + const result = wire(request([exec, foreign], [""], { namespace: foreign.namespace })); + expect(result.results[0].content[0].text).toBe(KIRO_EMPTY_TOOL_RESULT_MESSAGE); + }); + + test("a namespaced shell helper does not cancel real code mode", () => { + expect(wire(request([exec, { name: "exec_command", namespace: "mcp__remote" }])).instructions).toContain("Nothing in the isolate"); + }); + + test.each([ + "TypeError: tool `apply_patch` expects a string input", + "Error: The first line of the patch must be '*** Begin Patch'", + "Error: The last line of the patch must be '*** End Patch'", + "Unsupported import in exec: node:fs", + ])("adds one actionable hint to a genuine host failure: %s", output => { + const text = wire(request([exec], [output])).results[0].content[0].text; + expect(text).toStartWith(output); + expect(text).toContain("[recovery: "); + expect(wire(request([exec], [text])).results[0].content[0].text).toBe(text); + }); + + test("source reads that quote diagnostics do not become errors", () => { + for (const output of ["The documentation says: Unsupported import in exec: node:fs", "Script completed\nOutput:\nTypeError: tool `apply_patch` expects a string input"]) { + expect(wire(request([exec], [output])).results[0].content[0].text).toBe(output); + } + }); + + test("nonempty wrapper payloads and duplicate empty markers are never discarded", () => { + for (const output of ["Script completed\nOutput:\n0", "Script failed\nOutput:\n\n", `Script failed\nOutput:\n${" ".repeat(100_000)}real error`]) { + expect(wire(request([exec], [output])).results[0].content[0].text).toBe(output); + } + }); + + test("result normalization does not cross an intervening user message", () => { + const parsed = request([exec], ["", ""]); + parsed.context.messages.splice(3, 0, { role: "user", content: "Stop and explain first." }); + expect(() => wire(parsed)).toThrow("no matching tool use"); + }); + + test("custom_tool_call_output from the actual Responses parser gets the same repair", () => { + const parsed = parseRequest({ model: "kiro/claude-sonnet-4.5", input: [ + { role: "user", content: "Inspect the files" }, + { type: "custom_tool_call", call_id: "call_exec", name: "exec", input: "await tools.exec_command({cmd:'ls'})" }, + { type: "custom_tool_call_output", call_id: "call_exec", output: "" }, + ], tools: [{ type: "custom", name: "exec", description: "JavaScript in a V8 isolate", format: { type: "text" } }] }); + expect(wire(parsed).results[0].content[0].text).toContain("not lost context"); + }); +}); + +describe("Kiro task continuity: bounded catalog retains the execution path", () => { + test("keeps ordinary declaration order below the budget", () => { + const tools = [...filler(2), exec]; + expect(names(convertKiroToolContext(request(tools)).tools)).toEqual(tools.map(tool => tool.name)); + }); + + test("retains discovered tools, exec and the search gateway ahead of filler", () => { + const loaded: OcxTool = { name: "discovered", loadedFromToolSearch: true }; + const search: OcxTool = { name: "tool_search", toolSearch: true }; + const context = convertKiroToolContext(request([...filler(50), search, exec, loaded])); + expect(names(context.tools).slice(0, 3)).toEqual(["discovered", "exec", "tool_search"]); + expect(context.tools).toHaveLength(MAX_KIRO_TOOL_COUNT); + expect(context.systemAdditions.join(" ")).not.toContain("unavailable this turn: exec"); + }); + + test("reserves exec even when discovered tools alone fill every slot", () => { + const tools = filler(MAX_KIRO_TOOL_COUNT).map(tool => ({ ...tool, loadedFromToolSearch: true })); + const context = convertKiroToolContext(request([...tools, exec])); + expect(context.tools).toHaveLength(MAX_KIRO_TOOL_COUNT); + expect(names(context.tools)).toContain("exec"); + }); + + test("reserves exec within the byte budget as well as the count budget", () => { + const large = filler(30).map(tool => ({ ...tool, parameters: { type: "object", description: "x".repeat(7_000) } })); + const context = convertKiroToolContext(request([...large, exec])); + expect(names(context.tools)).toContain("exec"); + expect(new TextEncoder().encode(JSON.stringify(context.tools)).byteLength).toBeLessThanOrEqual(MAX_KIRO_TOOL_CATALOG_BYTES); + }); + + test("never lets an oversized reserved exec bypass the byte budget", () => { + const oversized = { ...exec, parameters: { type: "object", description: "x".repeat(MAX_KIRO_TOOL_CATALOG_BYTES) } }; + expect(() => convertKiroToolContext(request([oversized]))).toThrow("exec exceeds"); + }); + + test("emitted rather than requested shell tools determine the code-mode contract", () => { + const parsed = request([...filler(MAX_KIRO_TOOL_COUNT), exec, { name: "exec_command" }]); + const result = wire(parsed); + expect(result.names).toContain("exec"); + expect(result.names).not.toContain("exec_command"); + expect(result.instructions).toContain("Nothing in the isolate"); + }); + + test("tool_choice none neither reserves tools nor advertises code mode", () => { + const parsed = request([...filler(50), exec]); + parsed.options.toolChoice = "none"; + const result = wire(parsed); + expect(result.names).toEqual([]); + expect(result.instructions).not.toContain("Nothing in the isolate"); + }); +}); From a3f0fbe54b9c661c5275396bb11865629d6887d0 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:13:01 +0900 Subject: [PATCH 2/3] fix(kiro): preserve commentary task memory Keep Responses commentary in Kiro history so tool-result continuations and checkpoint compaction retain decisions, completed work, and next steps. Preserve the anti-repeat completion contract and prove historical commentary is input-only rather than automatically re-emitted. --- .../000_findings.md | 28 +++++++++-- .../troubleshooting/kiro-task-repetition.md | 4 +- .../troubleshooting/kiro-task-repetition.md | 4 +- src/adapters/kiro-constants.ts | 2 +- src/adapters/kiro.ts | 20 ++++---- structure/04_transports-and-sidecars.md | 22 +++++---- tests/kiro-stream.test.ts | 45 +++++++++++++----- tests/kiro-task-continuity.test.ts | 46 +++++++++++++++++++ 8 files changed, 131 insertions(+), 40 deletions(-) diff --git a/devlog/_plan/260912_kiro_task_continuity/000_findings.md b/devlog/_plan/260912_kiro_task_continuity/000_findings.md index 4e9645cbc6..1271d7ca36 100644 --- a/devlog/_plan/260912_kiro_task_continuity/000_findings.md +++ b/devlog/_plan/260912_kiro_task_continuity/000_findings.md @@ -1,4 +1,4 @@ -# Kiro task repetition: missing code-mode contracts +# Kiro task repetition: continuity gaps Date: 2026-09-12 (Asia/Seoul). @@ -7,7 +7,7 @@ Date: 2026-09-12 (Asia/Seoul). Work is against the user fork `coseung2/opencodex`, based on `main` at `6aec2590b` (`2.8.0-cs.20`). Its actual GitHub parent is `lidge-jun/opencodex`; upstream `dev` was inspected at `7a0513c2f`. This is a scoped adaptation, not an upstream merge. Existing fork -commentary/image retirement and terminal behavior must survive. +image retirement, reasoning replay, and terminal behavior must survive. ## Upstream comparison @@ -20,6 +20,7 @@ commentary/image retirement and terminal behavior must survive. | #2835 / #3012 / #3031 | Superseded prose, terminal completion, and blocking-question completion already present (`e2b2d453c`, `4ca112156`, `0055bdb7a`). | Preserve; do not reapply or weaken. | | #3750 / issues #3734 and #3731 | Original-id adjacent result grouping already present (`7445490e2`). | Normalize after that grouping; never merge across a user/assistant barrier. | | #543 / reasoning round-trip | Mid-turn user steering and redacted reasoning pairing already covered. | Preserve existing tests and ownership. | +| Fork-only `037a30984` (`fix(kiro): stop replaying progress commentary`) | The fork blanked every assistant `phase: "commentary"` before rebuilding Kiro history. Current upstream preserves assistant text. The same fork commit already added an explicit instruction not to repeat/paraphrase old progress. | Reverse only the commentary deletion; retain the anti-repeat instruction and stream phasing. | ## Findings and confidence @@ -37,6 +38,17 @@ order is preserved below budget; over budget, discoveries outrank filler and exe slot and bytes. Unlike a blind reservation, a single oversized exec fails rather than bypassing the 96,000-byte limit. +A third deterministic continuity failure was fork-specific. Commit `037a30984` classified Responses +`phase: "commentary"` as disposable UI prose and blanked it before rebuilding Kiro history. That +also removed substantive progress such as decisions, completed steps, rejected hypotheses, and next +actions. Tool calls/results survived, but the model could see what happened without the explanation +of why it happened or what remained. Current upstream preserves this assistant text. The deletion +was especially damaging at compaction boundaries: routed compaction summarizes the same rebuilt +provider history, so omitted commentary could not enter the checkpoint summary and was then lost +from replacement history. The fix preserves commentary as provider input memory while retaining the +existing instruction that forbids repeating/paraphrasing earlier progress; historical input is not +emitted by the response stream parser. + Known host errors can similarly encourage repeated invalid calls. Only verified code-mode calls receive a recovery hint. Structured tools named exec, unrelated MCP namespaces, successful output quoting a diagnostic, error status, images (including retired replay images), and nonempty @@ -46,9 +58,13 @@ notification order remain protected by tests. The proxy does not execute or retr - Wrote failing payload regressions before implementation. Empty-output guidance and catalog preservation failed against the baseline; fixtures for optional tools/images were corrected - before the final run. + before the final run. Commentary-preservation regressions were then written against the PR branch: + four tests failed exactly on blanked tool-round commentary, dropped commentary-only turns, and + missing compaction-summary context before the deletion logic was reversed. - Focused Kiro adapter, stream, reasoning round-trip, public-server completion, catalog-nudge, - and new continuity coverage: **216 pass, 0 fail, 783 assertions** across six files. + Responses parser/compaction, and continuity coverage after the commentary fix: **264 pass, 0 fail, + 883 assertions** across eight files. The narrower commentary/tool-round/compaction regressions are + **138 pass, 0 fail, 391 assertions** across two files. - `bun run typecheck`: passed with the pinned Bun 1.4.0 installation. - Frozen root dependency installation: passed without lockfile changes. - Documentation frozen install/build: passed, 231 pages. English and Korean troubleshooting @@ -62,6 +78,10 @@ notification order remain protected by tests. The proxy does not execute or retr unmodified worktree at baseline `6aec2590b`: **0 pass, 3 fail**, all at the startup health check (`tests/shutdown-launcher.test.ts:94`), before signal handling. No unrelated launcher source fix is included. The full suite was not repeated after dependency installation. +- A repository-wide rerun after the commentary change was started but did not produce an aggregate: + the Bun test process remained CPU-bound for 576 seconds and was interrupted rather than reported + as green. The earlier baseline full-suite limitations and clean-main launcher reproduction above + remain the only completed repository-wide comparison. No claim of a green full suite is made. - Staged `bun run privacy:scan` and `git diff --check`: passed. No dependency/lockfile changes. No real Kiro account request, credential mutation, automatic deployment, or operational restart diff --git a/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md b/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md index bfd557de32..c69664856e 100644 --- a/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md +++ b/docs-site/src/content/docs/ko/troubleshooting/kiro-task-repetition.md @@ -29,7 +29,9 @@ Kiro에 보내는 클라이언트 도구 목록에는 48개·직렬화 크기 96 기존 Kiro 비공개 완료 채널은 그대로 턴을 종료합니다. 진행 메시지는 최종 답변이 아니며, 이미 전달한 최종 답변 때문에 닫힌 작업을 다시 열어서는 안 됩니다. 실제 사용자 후속 질문은 정상적으로 처리합니다. 이 수정은 도구 이력을 버리거나, 추론 상태 재전송을 바꾸거나, 인자가 같다는 이유만으로 도구 호출을 삭제하거나, 명령을 자동 재실행하지 않습니다. -회귀 테스트는 빈 결과 설명 누락과 도구 목록 탈락을 재현하지만 모든 모델 반복 현상의 원인이 같다는 뜻은 아닙니다. 수정 빌드 배포 후에도 반복된다면 직전 결과가 빈 출력인지, 오류인지, 정상 결과인지, 이미 전달한 최종 답변인지 구분해야 합니다. 실행 중인 버전과 민감 정보를 제거한 요청 식별자를 기록하되 자격 증명이나 원문 비공개 프롬프트를 공개하지 않습니다. +`phase: "commentary"`로 표시된 assistant 메시지도 Kiro 이력에 보존합니다. 진행 문구에는 결정 사항, 이미 끝낸 단계, 폐기한 가설, 다음 미완료 작업이 들어갈 수 있으므로 이를 삭제하면 다음 도구 결과 라운드에서 작업 상태를 잃은 것처럼 보일 수 있습니다. routed compaction이 체크포인트 요약을 만들 때도 같은 이력이 입력으로 사용되므로 commentary를 보존해야 압축 뒤에 이런 결정이 사라지지 않습니다. 과거 commentary는 입력 문맥일 뿐 UI에 자동으로 다시 출력되지 않으며, Kiro에는 이전 진행 문구를 반복하거나 바꿔 말하지 말라는 지침을 계속 제공합니다. + +회귀 테스트는 빈 결과 설명 누락, 도구 목록 탈락, commentary 소실, 압축 입력 문맥 소실을 재현하지만 모든 모델 반복 현상의 원인이 같다는 뜻은 아닙니다. 수정 빌드 배포 후에도 반복된다면 직전 결과가 빈 출력인지, 오류인지, 정상 결과인지, 이미 전달한 최종 답변인지, 또는 compaction 경계를 지난 직후인지 구분해야 합니다. 실행 중인 버전과 민감 정보를 제거한 요청 식별자를 기록하되 자격 증명이나 원문 비공개 프롬프트를 공개하지 않습니다. ## 원본 저장소 참고 diff --git a/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md b/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md index 933e9c4581..823c23987f 100644 --- a/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md +++ b/docs-site/src/content/docs/troubleshooting/kiro-task-repetition.md @@ -29,7 +29,9 @@ Below the budget, declaration order is unchanged. Namespaced MCP shell tools do Kiro's existing private completion channel still ends the turn. Progress text is not a final answer, and a delivered final answer must not reopen the task. Real user follow-ups continue normally. This correction does not discard tool history, change reasoning-blob replay, remove repeated calls solely because their arguments match, or introduce automatic command retries. -The adapter tests reproduce missing-output guidance and catalog eviction deterministically; they do not prove that every model-generated repetition has the same cause. If repetition persists after deploying the corrected build, distinguish whether the preceding output was empty, an error, a successful result, or an already-delivered final answer. Record the running version and sanitized request identifiers, not credentials or raw private prompts. +Assistant messages marked `phase: "commentary"` are preserved in the Kiro history. They can contain decisions, completed steps, rejected hypotheses, and the next unfinished action, so dropping them can make a later tool-result round look as if the task state was lost. The same history is used when a routed compaction turn creates its checkpoint summary, so preserving commentary also prevents those decisions from disappearing at compaction. Historical commentary is input context only; OpenCodex does not automatically stream it to the UI again, and Kiro is explicitly instructed not to repeat or paraphrase earlier progress updates. + +The adapter tests reproduce missing-output guidance, catalog eviction, commentary loss, and compaction-context loss deterministically; they do not prove that every model-generated repetition has the same cause. If repetition persists after deploying the corrected build, distinguish whether the preceding output was empty, an error, a successful result, an already-delivered final answer, or a compaction boundary. Record the running version and sanitized request identifiers, not credentials or raw private prompts. ## Upstream references diff --git a/src/adapters/kiro-constants.ts b/src/adapters/kiro-constants.ts index 4907f3de8b..47964ee54d 100644 --- a/src/adapters/kiro-constants.ts +++ b/src/adapters/kiro-constants.ts @@ -17,7 +17,7 @@ export const KIRO_ANSWER_DELIVERED_MESSAGE = "The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction."; export const KIRO_COMPLETION_INSTRUCTIONS = - `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text. This completion tool is not an ordinary work tool. When the task is complete, call it instead of emitting answer-shaped ordinary assistant text. The call is terminal and is the exception to generic tool-result counting: it is complete when issued, ends the turn, returns no tool result, and no text or tool call may follow it. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, that question is your final answer: call ${KIRO_COMPLETION_TOOL_NAME} with the question and stop. Do not write the question as ordinary text and then answer it yourself.`; + `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Historical assistant commentary is durable task memory: use it to preserve decisions, completed steps, rejected hypotheses, and the next unfinished action. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text. This completion tool is not an ordinary work tool. When the task is complete, call it instead of emitting answer-shaped ordinary assistant text. The call is terminal and is the exception to generic tool-result counting: it is complete when issued, ends the turn, returns no tool result, and no text or tool call may follow it. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, that question is your final answer: call ${KIRO_COMPLETION_TOOL_NAME} with the question and stop. Do not write the question as ordinary text and then answer it yourself.`; export type KiroCompletionMode = "disabled" | "required" | "text_fallback"; diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index a7de06aa9e..45c4d6bed5 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -628,13 +628,12 @@ export function buildKiroPayload( .filter((b): b is OcxTextContent => b.type === "text") .map(b => b.text) .join(""); - // Responses commentary is transient UI progress, not durable assistant state. Replaying it - // into every Kiro tool-result continuation teaches the model to repeat the same update and - // grows the upstream context on each client-driven tool round. Keep the structural tool use, - // but omit commentary prose from normal history. The adapter-owned completion retry appends - // its one replayable assistant turn without a commentary phase below, so that bounded path is - // preserved. - const text = aMsg.phase === "commentary" ? "" : rawText; + // Responses commentary is already-visible UI progress, but it can also carry durable task + // state: decisions, completed steps, rejected hypotheses, and the next action. Preserve it in + // Kiro's explicit history so tool-result continuations and compaction can resume from that + // state. Historical input is never re-emitted by the stream parser; the completion contract + // separately tells Kiro not to repeat or paraphrase an earlier progress update. + const text = rawText; const toolCalls = (aMsg.content || []) .filter((b): b is OcxToolCall => b.type === "toolCall"); const toolUses: KiroToolUse[] = toolCalls.map(tc => { @@ -648,7 +647,7 @@ export function buildKiroPayload( }); if (!text && toolUses.length === 0) { const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim()); - if (hasReasoning || aMsg.phase === "commentary") continue; + if (hasReasoning) continue; } pushAssistant( text, @@ -2028,9 +2027,8 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter retryParsed.context.messages.push({ role: "assistant", content: [{ type: "text" as const, text: assistantText }], - // Intentionally omit `phase:"commentary"`: normal client commentary is filtered from - // Kiro history, while this adapter-owned one-shot replay is the evidence the bounded - // completion retry must validate. + // This adapter-owned one-shot replay is durable evidence for the bounded completion retry. + // It is not emitted by history replay; only the provider's new response is streamed. model: retryParsed.modelId, timestamp: Date.now(), }); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index baef48b43c..4bfd64b942 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -332,10 +332,12 @@ deadline for the next client replay. Retries are bounded to three attempts; hard ordinary 5xx errors are not replayed. Completion fallback rebuilds only replayable text, preserves the original user/tool-result turn for reasoning-only attempts, supplies neutral non-empty carriers for empty tool output, and validates role alternation plus tool-use/result pairing before transport. -Responses assistant prose marked `phase: "commentary"` remains client-visible but is omitted from -normal Kiro continuation history; paired tool uses and results remain intact. This keeps transient -progress from conditioning repeated updates or inflating every later tool round, while the -adapter-owned one-shot completion retry still replays its own validation text. +Responses assistant prose marked `phase: "commentary"` remains client-visible and is also preserved +in Kiro continuation history because it can carry durable task state: decisions, completed steps, +rejected hypotheses, and the next unfinished action. Historical commentary is input context only; it +is not re-emitted by the stream parser. The completion instruction still forbids repeating or +paraphrasing an earlier progress update, so continuity and duplicate-UI suppression are separate +concerns rather than solving repetition by deleting memory. Responses continuation replay records both its raw-item prefix and the parsed-message count produced by that prefix. If a client already supplied the exact stored prefix together with @@ -347,12 +349,12 @@ inspection is therefore scoped to the turn that introduced an image; a later tur again when the pixels themselves are needed. [Decision Log] -- 목적과 의도: Prevent Kiro progress from becoming a false final answer, reject invalid empty completion retries, stop duplicate Responses replay, retire completed-turn image bytes, and keep concurrent transient 429s from consuming independent retry budgets. -- 기존 구현 및 제약 조건: Kiro text has no trustworthy phase; stop metadata arrives only at stream end; Kiro requires explicit history even with a stable conversation id; the private completion tool is adapter-owned; current user/tool-result images must survive; normal parallel tool traffic must remain parallel; client cancellation must interrupt all waits. -- 검토한 주요 대안: Trust native `END_TURN`; infer completion from wording; send only a Kiro conversation id or current delta; keep every historical image forever; guess image relevance from prompt wording; serialize every Kiro request; leave throttling entirely to the client; manufacture empty assistant turns to preserve alternation. -- 선택한 방식: Require the private completion tool on tool-enabled turns, rebuild only valid replayable wire turns, de-duplicate an exact already-supplied Responses prefix, mark its parsed-message boundary, omit image bytes only inside that completed prefix, validate the final conversation, and activate a shared cooldown plus single probe only after a transient throttle. -- 다른 대안 대신 이 방식을 선택한 이유: Native stop metadata has mislabeled progress, wording and image-relevance heuristics are language-dependent, Kiro's wire still needs explicit text/tool history, global serialization harms healthy concurrency, client-only retries amplify bursts, and empty structural turns are rejected upstream. -- 장점, 단점 및 영향: Completion phase is deterministic, duplicate history and stale visual context no longer accumulate, current-turn images and tool pairing remain valid, and throttled concurrency recovers without a request storm; some clean Kiro stops pay one bounded validation call, an exactly repeated completion answer may be shown twice to preserve `final_answer` semantics, and later pixel-level image follow-ups must reattach the image. +- 목적과 의도: Prevent Kiro progress from becoming a false final answer without erasing progress that is needed as task memory; reject invalid empty completion retries, stop duplicate Responses replay, retire completed-turn image bytes, and keep concurrent transient 429s from consuming independent retry budgets. +- 기존 구현 및 제약 조건: Kiro text has no trustworthy terminal phase; commentary can contain substantive state; stop metadata arrives only at stream end; Kiro requires explicit history even with a stable conversation id; the private completion tool is adapter-owned; current user/tool-result images must survive; normal parallel tool traffic must remain parallel; client cancellation must interrupt all waits. +- 검토한 주요 대안: Trust native `END_TURN`; infer completion from wording; delete all historical commentary; send only a Kiro conversation id or current delta; keep every historical image forever; guess image relevance from prompt wording; serialize every Kiro request; leave throttling entirely to the client; manufacture empty assistant turns to preserve alternation. +- 선택한 방식: Require the private completion tool on tool-enabled turns, preserve commentary as explicit Kiro history while instructing the model not to repeat it, rebuild only valid replayable wire turns, de-duplicate an exact already-supplied Responses prefix, mark its parsed-message boundary, omit image bytes only inside that completed prefix, validate the final conversation, and activate a shared cooldown plus single probe only after a transient throttle. +- 다른 대안 대신 이 방식을 선택한 이유: Deleting commentary removed decisions and next-step state from later tool rounds and from compaction input; native stop metadata has mislabeled progress, wording and image-relevance heuristics are language-dependent, Kiro's wire still needs explicit text/tool history, global serialization harms healthy concurrency, client-only retries amplify bursts, and empty structural turns are rejected upstream. +- 장점, 단점 및 영향: Completion phase is deterministic, task memory survives tool rounds and checkpoint compaction, historical commentary is not automatically emitted to the UI, duplicate input/image history stays bounded, current-turn images and tool pairing remain valid, and throttled concurrency recovers without a request storm; preserving commentary consumes context proportional to real progress text, some clean Kiro stops pay one bounded validation call, an exactly repeated completion answer may be shown twice to preserve `final_answer` semantics, and later pixel-level image follow-ups must reattach the image. Historical `web_search_call` output items from previous Responses turns are not converted into assistant text. They are UI/search-cell evidence, not a replayable search result payload; turning diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index c3671f2bb8..b0720ad650 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -1758,18 +1758,19 @@ describe("kiro adapter — parseStream", () => { ]); }); - test("does not replay Responses commentary prose across Kiro tool-result rounds", async () => { - const repeatedProgress = "I am checking the same state again."; + test("preserves Responses commentary as durable Kiro history across tool-result rounds", async () => { + const firstProgress = "Repository status is clean; next I will inspect the latest commit."; + const secondProgress = "The latest commit is abc123; next I will inspect the affected adapter."; const parsed = parseRequest({ model: "claude-sonnet-4.5", stream: true, tools: [{ type: "function", ...bashTool }], input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "inspect the repository" }] }, - { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: repeatedProgress }] }, + { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: firstProgress }] }, { type: "function_call", call_id: "call-1", name: "bash", arguments: '{"command":"git status"}' }, { type: "function_call_output", call_id: "call-1", output: "clean" }, - { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: repeatedProgress }] }, + { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: secondProgress }] }, { type: "function_call", call_id: "call-2", name: "bash", arguments: '{"command":"git log -1"}' }, { type: "function_call_output", call_id: "call-2", output: "abc123" }, ], @@ -1778,14 +1779,15 @@ describe("kiro adapter — parseStream", () => { const { body } = await createKiroAdapter(provider).buildRequest(parsed); const state = JSON.parse(body).conversationState; - expect(body).not.toContain(repeatedProgress); + expect(body).toContain(firstProgress); + expect(body).toContain(secondProgress); expect(state.history).toHaveLength(4); expect(state.history[1].assistantResponseMessage).toEqual({ - content: "", + content: firstProgress, toolUses: [{ name: "bash", input: { command: "git status" }, toolUseId: "call-1" }], }); expect(state.history[3].assistantResponseMessage).toEqual({ - content: "", + content: secondProgress, toolUses: [{ name: "bash", input: { command: "git log -1" }, toolUseId: "call-2" }], }); expect(state.currentMessage.userInputMessage.userInputMessageContext.toolResults).toEqual([ @@ -1793,20 +1795,39 @@ describe("kiro adapter — parseStream", () => { ]); }); - test("drops commentary-only assistant turns instead of creating invalid empty Kiro history", async () => { + test("preserves commentary-only assistant turns as task memory", async () => { const { body } = await createKiroAdapter(provider).buildRequest(parsedWith([ { role: "user", content: "first instruction" }, - { role: "assistant", phase: "commentary", content: [{ type: "text", text: "Still checking." }] }, + { role: "assistant", phase: "commentary", content: [{ type: "text", text: "Still checking; the first hypothesis was ruled out." }] }, { role: "user", content: "second instruction" }, ], [bashTool])); const state = JSON.parse(body).conversationState; - expect(body).not.toContain("Still checking."); - expect(state.history).toBeUndefined(); - expect(state.currentMessage.userInputMessage.content).toContain("first instruction"); + expect(state.history).toHaveLength(2); + expect(state.history[0].userInputMessage.content).toContain("first instruction"); + expect(state.history[1].assistantResponseMessage.content).toBe("Still checking; the first hypothesis was ruled out."); expect(state.currentMessage.userInputMessage.content).toContain("second instruction"); }); + test("preserved historical commentary is input-only and is not re-emitted by the stream parser", async () => { + const historical = "Already shown progress: configuration is valid; next inspect the adapter."; + const adapter = createKiroAdapter(provider); + const { body } = await adapter.buildRequest(parsedWith([ + { role: "user", content: "inspect it" }, + { role: "assistant", phase: "commentary", content: [{ type: "text", text: historical }] }, + { role: "user", content: "continue" }, + ])); + expect(body).toContain(historical); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "Fresh provider output only." }), + )))); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Fresh provider output only." }, + ]); + expect(events.some(event => event.type === "text_delta" && event.text.includes(historical))).toBe(false); + }); + test("resumed tool-result usage remains current-turn only after payload repair", async () => { const messages = [ { role: "user", content: "u".repeat(8000) }, diff --git a/tests/kiro-task-continuity.test.ts b/tests/kiro-task-continuity.test.ts index 11f4b2ee84..8d4a63adf0 100644 --- a/tests/kiro-task-continuity.test.ts +++ b/tests/kiro-task-continuity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildKiroPayload } from "../src/adapters/kiro"; import { KIRO_COMPLETION_TOOL_NAME, KIRO_EMPTY_TOOL_RESULT_MESSAGE } from "../src/adapters/kiro-constants"; import { convertKiroToolContext, MAX_KIRO_TOOL_CATALOG_BYTES, MAX_KIRO_TOOL_COUNT } from "../src/adapters/kiro-tools"; +import { COMPACT_PROMPT } from "../src/responses/compaction"; import { parseRequest } from "../src/responses/parser"; import type { OcxMessage, OcxParsedRequest, OcxTool } from "../src/types"; @@ -146,6 +147,51 @@ describe("Kiro task continuity: code-mode contract and tool results", () => { }); }); +describe("Kiro task continuity: commentary survives continuation and compaction", () => { + test("tool-result continuation keeps prior commentary decisions in provider history", () => { + const decision = "The migration already ran successfully; do not run it again. Next verify the generated files."; + const parsed = parseRequest({ + model: "claude-sonnet-4.5", + stream: true, + tools: [{ type: "function", name: "read_file", description: "read", parameters: { type: "object" } }], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "finish the migration work" }] }, + { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: decision }] }, + { type: "function_call", call_id: "call_read", name: "read_file", arguments: "{}" }, + { type: "function_call_output", call_id: "call_read", output: "generated files exist" }, + ], + }); + const state = buildKiroPayload(parsed, undefined).payload.conversationState as { + history?: Array<{ assistantResponseMessage?: { content: string } }>; + }; + expect(state.history?.some(entry => entry.assistantResponseMessage?.content === decision)).toBe(true); + }); + + test("compaction summarizer receives commentary that carries progress and next steps", () => { + const checkpoint = "Root cause confirmed in the Kiro adapter. Keep the completed edits; next run the focused regression suite."; + const parsed = parseRequest({ + model: "claude-sonnet-4.5", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "investigate the repeated-work bug" }] }, + { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: checkpoint }] }, + { type: "compaction_trigger" }, + ], + }); + expect(parsed._compactionRequest).toBe(true); + // This is the same prompt append performed by the routed-compaction server path before the + // provider adapter is invoked. The Kiro payload must retain the preceding commentary so the + // summary can carry progress/decisions into the replacement history. + parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: 0 }); + const state = buildKiroPayload(parsed, undefined).payload.conversationState as { + history?: Array<{ assistantResponseMessage?: { content: string } }>; + currentMessage: { userInputMessage: { content: string } }; + }; + expect(state.history?.some(entry => entry.assistantResponseMessage?.content === checkpoint)).toBe(true); + expect(state.currentMessage.userInputMessage.content).toContain("CONTEXT CHECKPOINT COMPACTION"); + }); +}); + describe("Kiro task continuity: bounded catalog retains the execution path", () => { test("keeps ordinary declaration order below the budget", () => { const tools = [...filler(2), exec]; From 360776b133bcfa8df181154b934fd5d0c0aaa834 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:43:26 +0900 Subject: [PATCH 3/3] refactor(kiro): separate native provider boundaries Split Kiro task-continuity policy, bidirectional CodeWhisperer codec, native auth/transport, and the stable ProviderAdapter facade without changing provider behavior. Add architecture-boundary tests and document the dependency rules, validation, rollback, and security review. --- .../260912_kiro_native_boundaries/000_plan.md | 294 +++ .../000_findings.md | 9 + src/adapters/kiro-codec.ts | 1790 ++++++++++++++ src/adapters/kiro-continuity.ts | 130 ++ src/adapters/kiro-transport.ts | 153 ++ src/adapters/kiro.ts | 2076 +---------------- structure/04_transports-and-sidecars.md | 41 +- tests/kiro-architecture-boundary.test.ts | 58 + 8 files changed, 2524 insertions(+), 2027 deletions(-) create mode 100644 devlog/_plan/260912_kiro_native_boundaries/000_plan.md create mode 100644 src/adapters/kiro-codec.ts create mode 100644 src/adapters/kiro-continuity.ts create mode 100644 src/adapters/kiro-transport.ts create mode 100644 tests/kiro-architecture-boundary.test.ts diff --git a/devlog/_plan/260912_kiro_native_boundaries/000_plan.md b/devlog/_plan/260912_kiro_native_boundaries/000_plan.md new file mode 100644 index 0000000000..fbda910ada --- /dev/null +++ b/devlog/_plan/260912_kiro_native_boundaries/000_plan.md @@ -0,0 +1,294 @@ +# Kiro native-boundary refactor plan + +Date: 2026-09-12 (Asia/Seoul) +Branch: `fix/kiro-task-continuity-20260912` +Target PR: `coseung2/opencodex#2` → `main` + +## Goal + +Make Kiro direct connectivity structurally match the rest of OpenCodex: canonical Responses history and task-continuity state stay provider-agnostic, while the Kiro-specific layer only translates that state into CodeWhisperer wire shapes and performs native transport/auth/event-stream work. + +The current `src/adapters/kiro.ts` is 2,163 lines and owns request history rebuilding, completion/fallback policy, token/accounting policy, CodeWhisperer payload encoding, auth/profile/region headers, endpoint construction, retry fetch wiring, AWS event-stream decoding, response normalization, and the final ProviderAdapter facade. The immediate commentary-loss bug came from a UI-progress policy being implemented inside that same payload builder. The refactor must make that class of cross-layer mutation harder to introduce. + +This unit is an architectural refactor after the functional continuity fixes already on PR #2. It must not intentionally change observable behavior. + +## Target architecture + +```text +Codex / Claude / Chat client + ↓ +canonical OcxParsedRequest + Responses history + ↓ +kiro-continuity.ts + terminal/completion/fallback policy only + no AWS headers, endpoint construction, event-stream decoding + ↓ +kiro-codec.ts + canonical history ↔ Kiro conversationState + AWS event-stream ↔ AdapterEvent + no credential resolution or network I/O + ↓ +kiro-transport.ts + Kiro region/profile/wire-client selection + GenerateAssistantResponse endpoint + headers + retry-aware fetch + ↓ +Kiro CodeWhisperer GenerateAssistantResponse + +kiro.ts + thin ProviderAdapter facade/orchestration across the three layers +``` + +`kiro.ts` remains the stable import surface so `resolveAdapter()` and existing tests/users do not need a repository-wide rename. Existing public test helpers (`buildKiroPayload`, `parseKiroStream`, `kiroReasoningMode`, `isRetryableKiroStreamCatchError`, `boundedInjectedInstructionForTests`) remain re-exported from the facade. Request encoding and event-stream decoding live together in `kiro-codec.ts` because they are the two directions of the same Kiro wire translation boundary; neither may resolve credentials or perform fetches. + +## Non-negotiable invariants + +1. Commentary continuity + - `phase: "commentary"` assistant text remains durable Kiro input history. + - Historical commentary is never automatically emitted as new output. + - Routed compaction sees the same preserved commentary. + - The existing anti-repeat instruction remains active. + +2. Completion semantics + - Tool-enabled Kiro turns use the private `codex_kiro_final_answer` completion contract. + - A delivered final answer terminates locally and is never reopened. + - Blocking questions remain valid terminal answers. + - Progress-only clean stops still receive at most one bounded completion validation. + - A completion call never receives or waits for a tool result. + +3. Tool/history validity + - Tool-use/result ids remain paired by original identity. + - Adjacent multi-output results remain coalesced only for the same original call. + - Tool-search discoveries and freeform `exec` stay within the count/byte catalog budgets. + - No command/tool is automatically re-executed by the adapter. + +4. Reasoning/image continuity + - Kiro redacted reasoning blobs retain their existing round-trip ownership. + - Current-turn images survive; retired replay-prefix image bytes remain omitted. + - Image evidence still prevents false “empty exec output” diagnostics. + +5. Native transport/auth + - OAuth/API-key distinction, Builder ID fallback, enterprise profile ARN, API region, runtime endpoint, user-agent, `x-amz-target`, token type, and retry behavior remain byte/semantics compatible with the current adapter. + - Client cancellation still aborts both the first request and the one bounded fallback request. + - Error bodies remain credential-safe and redacted. + +6. Usage/context accounting + - Request-log input estimates, current-turn usage, context-pressure estimates, calibration and conversation rekeying retain current semantics. + - Translator-budget retention/release behavior remains bounded on all terminal/error/cancel paths. + +7. Public surface + - `/v1/responses`, `/v1/chat/completions`, `/v1/messages`, non-streaming Responses, and provider routing keep existing behavior. + - No service restart, deployment, credential mutation, or account action is part of this change. + +## Detailed implementation phases + +### Phase 0 — Lock the baseline and architecture tests + +Before moving implementation code: + +- Record branch SHA and clean worktree state. +- Keep the current Kiro continuity regression suite green. +- Add architecture-boundary tests that assert: + - `src/adapters/kiro.ts` is a facade and delegates to explicit Kiro modules. + - continuity policy does not import transport/auth/network modules. + - codec does not import OAuth credential resolution or fetch/retry modules. + - transport does not own commentary/final-answer history policy. +- Do not weaken existing behavioral tests to make the refactor pass. + +Acceptance: tests fail if responsibilities drift back across the documented boundaries. + +### Phase 1 — Extract continuity policy + +Create `src/adapters/kiro-continuity.ts` and move policy-only logic there: + +- delivered-final detection used by local terminal handling; +- completion mode selection; +- bounded completion-tool/instruction contract construction; +- fallback-history preparation (provider continuation id + replayable assistant progress); +- exact fallback serialization upper-bound helper/constant where it is policy-owned. + +Rules: + +- no `resolveKiroApiRegion`, profile resolution, endpoint URL, headers, `fetch`, event-stream decoder or AWS constants; +- no direct mutation that drops canonical history based on UI phase; +- functions accept/return canonical request/policy data rather than network objects. + +Acceptance: local-terminal, progress fallback, blocking-question and delivered-answer tests remain unchanged and green. + +### Phase 2 — Extract native request codec + +Create `src/adapters/kiro-codec.ts` and move canonical-history → Kiro wire mapping there: + +- Kiro wire message/tool/result interfaces; +- `buildKiroPayload` and conversation-state validation; +- tool wire name mapping and catalog injection; +- commentary/history preservation; +- redacted reasoning placement; +- replay-prefix image retirement behavior; +- code-mode empty-result normalization; +- Kiro capability checks; +- reasoning-mode/thinking-tag mapping; +- token/context estimate helpers that depend on the serialized Kiro payload. + +The codec may depend on pure helpers (`kiro-tools`, `kiro-images`, `kiro-wire`, identity/tool nudge, continuity policy) but must not resolve account credentials or perform network I/O. + +Acceptance: all existing payload-shape tests pass without fixture changes except imports/re-exports. + +### Phase 3 — Extract native transport + +Create `src/adapters/kiro-transport.ts` and move network-facing request construction there: + +- Kiro CLI/IDE wire-client selection; +- Builder ID/API-key/profile behavior; +- region/profile resolution; +- runtime endpoint construction and fixed-host validation; +- native user-agent strings and AWS/CodeWhisperer headers; +- request serialization after image normalization; +- debug-safe request diagnostics; +- retry-aware `fetchKiroWithRetry` entry point. + +Expose a typed `buildKiroNativeRequest()` result containing only what the facade/stream layer needs: `AdapterRequest`, tool name map, conversation id, completion mode, current-turn input estimate and context estimate. + +Acceptance: auth/profile/runtime/401-replay/retry tests remain green; no secrets appear in diagnostics. + +### Phase 4 — Keep response decoding inside the native codec boundary + +Move CodeWhisperer response parsing into the same `src/adapters/kiro-codec.ts` module as request encoding: + +- AWS event-stream decoding; +- Kiro event classification; +- tool input assembly; +- thinking/reasoning parsing; +- staged commentary/final-answer phase mapping; +- usage/context checkpoint accounting and calibration; +- bounded retention/release; +- one bounded completion-attempt orchestration interface. + +The codec receives a fallback factory callback; it must not resolve credentials or build native headers itself. Keeping request encoding and response decoding together makes the codec the single Kiro wire-protocol owner while transport remains strictly network/auth. + +Acceptance: stream/reasoning/usage/context-pressure tests remain green and cancellation still releases retained budget. + +### Phase 5 — Make `kiro.ts` a thin facade + +Reduce `src/adapters/kiro.ts` to ProviderAdapter orchestration: + +- keep per-request state needed to bridge `buildRequest` → `parseStream`/`parseResponse`; +- delegate initial/fallback request construction to transport + continuity helpers; +- delegate parsing to `kiro-stream`; +- delegate local terminal to continuity; +- delegate error formatting to the existing error helper; +- re-export stable Kiro helper functions used by tests/other modules. + +Target: the facade should be small enough that a future change to commentary retention cannot be hidden among transport/eventstream code. A strict line-count target is not a correctness requirement, but roughly <350 lines is preferred. + +### Phase 6 — Documentation and decision log synchronization + +Update: + +- `structure/04_transports-and-sidecars.md` with the new dependency direction and ownership rules; +- Kiro troubleshooting docs only if user-visible behavior wording changes (none expected beyond clarifying architecture); +- this plan with actual file/validation outcomes; +- existing `260912_kiro_task_continuity` findings with a pointer to the structural follow-up. + +Document the important prohibition explicitly: transport/codec layers must not decide that a canonical assistant message is “just UI” and erase it. + +### Phase 7 — Validation gates + +Run in this order so failures identify the layer that moved incorrectly: + +1. New architecture-boundary tests. +2. Focused payload/continuity tests: + - `kiro-adapter` + - `kiro-task-continuity` + - `tool-catalog-nudge` +3. Stream/reasoning tests: + - `kiro-stream` + - `kiro-reasoning-roundtrip` +4. Auth/transport tests: + - `kiro-retry` + - `kiro-oauth` + - `server-kiro-oauth-401-replay` +5. Public endpoint/compaction tests: + - `server-kiro-completion-e2e` + - `responses-parser` + - `responses-compaction` +6. `bun run typecheck` +7. `bun run privacy:scan` +8. docs frozen install/build +9. repository-wide test run; any baseline/environment failure must be reported separately and never called green. + +### Phase 8 — Commit and push + +- Review `git diff --check`, staged privacy scan and final status. +- Commit the architecture refactor as a distinct logical commit on top of the two functional continuity commits already in PR #2. +- Push only `fix/kiro-task-continuity-20260912` to `origin`. +- Update PR #2 summary with the new architecture boundary, validation counts and any full-suite limitation. +- Confirm `enforce-target` succeeds under the repaired fork-main policy and inspect the main CI statuses. +- Do not merge, deploy or restart the operational proxy in this unit. + +## Risks and mitigations + +### Circular imports + +Risk: moving completion helpers, payload codec and transport can introduce `continuity ↔ codec ↔ transport` cycles. + +Mitigation: dependency direction is strict: `continuity` may depend on constants/types and canonical provider-agnostic turn-termination state only; `codec` may depend on continuity and pure Kiro helpers; `transport` may depend on codec; facade may depend on all. Codec may decode event streams but never imports OAuth/profile resolution or retry/fetch transport. Stream-level throttle observations leave the codec through a callback and are recorded by transport. + +### Hidden state split + +Risk: request-derived state currently lives in one adapter closure; extracting builders can accidentally lose model id, conversation id, tool map, completion mode, context estimates or abort signal. + +Mitigation: define one typed native-build result and assign facade state from it in one place. Add/retain tests for conversation-id reuse, calibration, fallback context growth and cancellation. + +### Behavior changes disguised as moves + +Risk: large code movement makes semantic drift difficult to review. + +Mitigation: no opportunistic provider behavior changes during extraction. Keep functional deltas separate from pure movement. Existing golden payload tests and stream tests are the acceptance oracle. + +### Memory/accounting regressions + +Risk: moved stream collectors can double-release or leak TranslatorBudget reservations. + +Mitigation: preserve current try/finally ownership and run near-cap/cancellation tests before broader suites. + +### Upstream incompatibility + +Risk: fork-only module layout can make future upstream cherry-picks harder. + +Mitigation: retain stable exported names from `kiro.ts`; isolate upstream-compatible behavior inside focused modules; document where upstream fixes should land. + +## Rollback strategy + +The refactor is one commit layered above already-tested functional fixes. If architecture extraction causes a regression that cannot be resolved without changing behavior, revert only the architecture commit; the earlier empty-exec/catalog/commentary continuity fixes remain intact. No migration or persisted state format is introduced, so rollback requires no data conversion. + +## Completion criteria + +This unit is complete only when: + +- the plan and structure docs match the implemented dependency graph; +- commentary and compaction continuity remain covered; +- Kiro payload/auth/stream/public-endpoint tests pass; +- typecheck/privacy/docs pass; +- `kiro.ts` is a thin orchestration facade with stable re-exports; +- the architecture commit is pushed to PR #2; +- PR branch-policy check is green; +- any remaining CI/full-suite limitation is recorded without claiming success. + +## Implementation outcome + +Implemented on 2026-09-12 in the existing PR #2 branch. + +- `src/adapters/kiro.ts`: 191-line stable facade. It owns only per-request orchestration, bounded fallback budget ownership, ProviderAdapter method wiring, and stable re-exports. +- `src/adapters/kiro-continuity.ts`: 130 lines. Owns delivered-final detection, completion-mode policy, private completion tool/instruction bounding, canonical fallback-history preparation, and exact replay-string size accounting. It has no auth/network/event-stream imports. +- `src/adapters/kiro-codec.ts`: 1,791 lines. Owns request encoding and response/event-stream decoding, including history/tool/result/reasoning/image/usage continuity. It has no credential resolution or fetch/retry dependency. Stream-level throttle observations leave through a callback rather than importing retry state. +- `src/adapters/kiro-transport.ts`: 153 lines. Owns Kiro native region/profile resolution, API-key/Builder-ID/IDE envelope selection, CodeWhisperer headers and endpoint, image-normalized serialization, safe request diagnostics, retry-aware fetch, and stream-throttle cooldown recording. +- `tests/kiro-architecture-boundary.test.ts`: pins the dependency rules so policy/network responsibilities cannot silently collapse back together. + +The architecture-boundary test was written first and failed 5/5 against the monolithic baseline. After the extraction it passes 4/4 (the finalized three-layer design intentionally folded event-stream decode into the bidirectional codec instead of a fourth stream module). + +Focused post-refactor validation across payload, continuity, tool catalog, stream, reasoning, OAuth, retry, 401 replay, public completion, Responses parser/compaction, and architecture boundaries: **357 pass, 0 fail, 1,206 assertions across 12 files** after the final throttle-callback boundary fix. `bun run typecheck`, `bun run privacy:scan`, and `git diff --check` passed. Repository hygiene passed **10/10** (17 assertions). Frozen docs install/build passed with **231 pages** and no lockfile changes. + +A repository-wide run was attempted with a deliberate 240-second ceiling because the same VM had already produced a 576-second CPU-bound incomplete run earlier in this unit. The bounded rerun made steady progress but hit the ceiling in `oauth-refresh-lock-multiprocess.test.ts` before producing an aggregate, exiting 124 after 242 seconds. This is recorded as an incomplete full-suite validation, not green and not a Kiro test failure. Earlier clean-main reproduction still accounts for the known three `shutdown-launcher` startup-health failures. + +Security review: the native auth/header/endpoint block was compared directly against `a3f0fbe54`. API-key detection, Builder-ID/profile selection, region resolution, canonical/custom endpoint behavior, `Authorization`, `tokentype`, `x-amz-target`, user agents, profile ARN, diagnostics, and retry fetch semantics are unchanged. No new credential source, network destination, permission, request-body logging, or account mutation was introduced. Focused OAuth, 401 replay, retry, adapter diagnostics, privacy scan, and architecture tests all passed. diff --git a/devlog/_plan/260912_kiro_task_continuity/000_findings.md b/devlog/_plan/260912_kiro_task_continuity/000_findings.md index 1271d7ca36..ee2e192521 100644 --- a/devlog/_plan/260912_kiro_task_continuity/000_findings.md +++ b/devlog/_plan/260912_kiro_task_continuity/000_findings.md @@ -88,3 +88,12 @@ No real Kiro account request, credential mutation, automatic deployment, or oper was performed. A read-only health check reported the operational service as `2.8.0-cs.18`; that version string alone does not establish which selectively copied modules are loaded. The source correction and deployment status are deliberately separate. + +## Structural follow-up + +The continuity findings above exposed a layering problem as well as individual bugs: one monolithic +Kiro adapter owned task policy, wire mapping, auth/transport and event decoding. The follow-up plan +and implementation are documented in `devlog/_plan/260912_kiro_native_boundaries/000_plan.md`. +That refactor keeps this unit's behavior while separating continuity policy, bidirectional Kiro codec, +native transport, and the stable ProviderAdapter facade. Post-refactor focused coverage expands to +**357 pass, 0 fail, 1,206 assertions across 12 files**, including explicit architecture-boundary tests. diff --git a/src/adapters/kiro-codec.ts b/src/adapters/kiro-codec.ts new file mode 100644 index 0000000000..76482dfda2 --- /dev/null +++ b/src/adapters/kiro-codec.ts @@ -0,0 +1,1790 @@ +import { decodeEventStream } from "../lib/eventstream-decoder"; +import { estimateTokens } from "../lib/token-estimate"; +import { debugProviderDiagnostic } from "../lib/debug"; +import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; +import { modelRecordValue } from "../reasoning-effort"; +import { parseKiroEvent } from "./kiro-events"; +import { calibrateKiroEstimate, recordKiroCalibration, rekeyKiroCalibration } from "./kiro-calibration"; +import { + classifyKiroEventError, + classifyKiroHttpError, + classifyKiroStreamError, + safeKiroErrorMessage, + type KiroErrorClassification, +} from "./kiro-errors"; +import { KiroThinkingParser } from "./kiro-thinking"; +import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation"; +import { createKiroToolNameRegistry, fallbackToolUseId, isValidKiroConversationId, mapModelId, normalizeToolId, stableConversationId } from "./kiro-wire"; +import { namespacedToolName } from "../types"; +import { + boundedKiroInjectedInstruction, + kiroCompletionTool, + resolveKiroCompletionMode, +} from "./kiro-continuity"; +export { boundedInjectedInstructionForTests } from "./kiro-continuity"; +import { + isTranslatorBudgetExceededError, + releaseTranslatedEvent, + retainTranslatedEvent, + type TranslatorBudget, +} from "../lib/translator-budget"; +import type { + AdapterEvent, + OcxAssistantMessage, + OcxContentPart, + OcxMessage, + OcxParsedRequest, + OcxTextContent, + OcxToolCall, + OcxToolResultMessage, + OcxUsage, +} from "../types"; +import { extractKiroImages, type KiroImage } from "./kiro-images"; +import { sniffImageDimensions } from "./anthropic-image-guard"; +import { convertKiroToolContext } from "./kiro-tools"; +import { normalizeCodeModeToolResult } from "./exec-tool-result-normalize"; +import { neutralizeIdentity } from "./identity"; +import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge"; +import { + KIRO_ANSWER_DELIVERED_MESSAGE, + KIRO_COMPLETION_INSTRUCTIONS, + KIRO_COMPLETION_RETRY_MESSAGE, + KIRO_COMPLETION_TOOL_NAME, + KIRO_CONTINUATION_MESSAGE, + KIRO_EMPTY_TOOL_RESULT_MESSAGE, + KIRO_TOOL_RESULT_CARRIER_MESSAGE, + type KiroCompletionMode, +} from "./kiro-constants"; + +export type KiroWireClient = "ide" | "cli"; + +// Payload construction (conversationState) +interface KiroToolUse { + name: string; + input: Record; // OBJECT, not stringified + toolUseId: string; +} +interface KiroToolResult { + content: Array<{ text: string }>; + status: string; + toolUseId: string; +} +interface KiroUserInputMessage { + content: string; + modelId?: string; + origin?: string; + userInputMessageContext?: { + tools?: unknown[]; + toolResults?: KiroToolResult[]; + }; + images?: KiroImage[]; +} +interface KiroHistoryEntry { + userInputMessage?: KiroUserInputMessage; + assistantResponseMessage?: { + content: string; + toolUses?: KiroToolUse[]; + reasoningContent?: { redactedContent: string }; + }; +} + +function kiroToolWireNames(tools: readonly unknown[]): string[] { + return tools + .map(tool => { + const spec = (tool as { toolSpecification?: { name?: unknown } }).toolSpecification; + return typeof spec?.name === "string" ? spec.name : undefined; + }) + .filter((name): name is string => typeof name === "string"); +} + +function userContentText(content: string | OcxContentPart[]): string { + if (typeof content === "string") return content; + return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); +} + +function usageContentText(content: string | OcxContentPart[]): string { + if (typeof content === "string") return content; + return content + .map(p => { + if (p.type === "text") return p.text; + if (p.type === "image") return `[image:${p.detail ?? "auto"}]`; + return ""; + }) + .filter(Boolean) + .join("\n"); +} +function serializeForUsage(value: unknown): string { + try { return JSON.stringify(value); } catch { return String(value); } +} +function currentTurnUsageMessages(messages: OcxMessage[]): OcxMessage[] { + return messages.slice(messages.map(m => m.role).lastIndexOf("assistant") + 1).filter(m => m.role !== "assistant"); +} +export function kiroPayloadMessages(parsed: OcxParsedRequest): OcxMessage[] { + return parsed.context.messages; +} + +function messageUsageText(msg: OcxMessage): string { + switch (msg.role) { + case "user": + case "developer": + return usageContentText(msg.content); + case "toolResult": + return [ + msg.toolName, + msg.toolCallId, + msg.isError ? "error" : "success", + usageContentText(msg.content), + ].filter(Boolean).join("\n"); + case "assistant": + return ""; + } +} + +function messageLogText(msg: OcxMessage): string { + if (msg.role !== "assistant") return messageUsageText(msg); + return msg.content.map(part => { + if (part.type === "text") return part.text; + if (part.type === "toolCall") return [part.name, part.id, serializeForUsage(part.arguments)].join("\n"); + return part.thinking; + }).filter(Boolean).join("\n"); +} + +function estimateKiroImageTokens(image: KiroImage): number { + const dimensions = sniffImageDimensions(image.source.bytes); + if (dimensions) { + return Math.max(256, Math.ceil(dimensions.width * dimensions.height / 750)); + } + const decodedBytes = Math.floor(image.source.bytes.length * 3 / 4); + return Math.max(256, Math.ceil(decodedBytes / 512)); +} + +function estimateKiroTokens(text: string, modelId?: string): number { + return estimateTokens(text, modelId ? `kiro/${modelId}` : "kiro"); +} + +// Per-entry JSON/role framing is invisible to the text walker but grows with conversation length. +const KIRO_ENTRY_FRAMING_TOKENS = 12; +// Newlines, quotes, tabs and backslashes expand when serialized onto the Kiro JSON wire. +const KIRO_JSON_ESCAPE_EXPANSION = 1.12; + +export function estimateKiroPayloadInputTokens(payload: Record, modelId: string): number { + const conversationState = (payload as { + conversationState?: { + history?: KiroHistoryEntry[]; + currentMessage?: KiroHistoryEntry; + }; + }).conversationState; + if (!conversationState) return 0; + + const parts: string[] = []; + let imageTokens = 0; + const entries = [ + ...(conversationState.history ?? []), + ...(conversationState.currentMessage ? [conversationState.currentMessage] : []), + ]; + for (const entry of entries) { + const user = entry.userInputMessage; + if (user) { + if (user.content) parts.push(user.content); + for (const image of user.images ?? []) imageTokens += estimateKiroImageTokens(image); + const context = user.userInputMessageContext; + if (context?.tools?.length) parts.push(serializeForUsage(context.tools)); + if (context?.toolResults?.length) parts.push(serializeForUsage(context.toolResults)); + } + const assistant = entry.assistantResponseMessage; + if (assistant) { + if (assistant.content) parts.push(assistant.content); + if (assistant.toolUses?.length) parts.push(serializeForUsage(assistant.toolUses)); + } + } + return Math.ceil(estimateKiroTokens(parts.join("\n"), modelId) * KIRO_JSON_ESCAPE_EXPANSION) + + imageTokens + + entries.length * KIRO_ENTRY_FRAMING_TOKENS; +} + +function shouldCountStablePromptOverhead(parsed: OcxParsedRequest): boolean { + return !parsed.previousResponseId && !parsed.context.messages.some(m => m.role === "assistant"); +} + +export function estimateKiroInputTokens(parsed: OcxParsedRequest): number { + const parts = currentTurnUsageMessages(parsed.context.messages) + .map(messageUsageText) + .filter(Boolean); + + if (shouldCountStablePromptOverhead(parsed)) { + if (parsed.context.systemPrompt?.length) parts.push(...parsed.context.systemPrompt); + if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); + } + + return estimateKiroTokens(parts.join("\n"), parsed.modelId); +} + +export function estimateKiroLogInputTokens(parsed: OcxParsedRequest): number { + const parts = parsed.context.messages.map(messageLogText).filter(Boolean); + if (parsed.context.systemPrompt?.length) parts.push(...parsed.context.systemPrompt); + if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); + return Math.max(estimateKiroInputTokens(parsed), estimateKiroTokens(parts.join("\n"), parsed.modelId)); +} + +export function kiroUpstreamContextWindow(modelId: string | undefined): number | undefined { + if (!modelId) return undefined; + const normalizedModelId = normalizeKiroModelId(modelId); + if (normalizedModelId === "auto") return undefined; + const window = modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, modelId) + ?? modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, normalizedModelId); + return typeof window === "number" && Number.isFinite(window) && window > 0 ? window : undefined; +} + +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 kiroNativeEffortField(modelId) ? "native" : "emulated"; +} + +function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined { + const effort = parsed.options.reasoning; + if (!effort || effort === "none") return undefined; + const maxTokens = parsed.options.maxOutputTokens || 4096; + const percent: Record = { + minimal: 0.10, + low: 0.20, + medium: 0.50, + high: 0.80, + xhigh: 0.90, + max: 0.95, + }; + const ratio = percent[effort]; + return ratio === undefined ? undefined : Math.max(1, Math.floor(maxTokens * ratio)); +} + +function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): string { + if (kiroReasoningMode(parsed.modelId) !== "emulated") return content; + const budget = kiroThinkingBudget(parsed); + if (!budget) return content; + const instruction = [ + "Think in English for better reasoning quality.", + "Be thorough and systematic, consider edge cases, challenge assumptions, and verify reasoning before answering.", + "After thinking, respond in the user's language.", + ].join("\n"); + return [ + "enabled", + `${budget}`, + `${instruction}`, + "", + content, + ].join("\n"); +} + +function validateKiroCapabilities(parsed: OcxParsedRequest): void { + const choice = parsed.options.toolChoice; + if (choice !== undefined && choice !== "auto" && choice !== "none") { + throw new Error("Kiro supports only automatic tool choice or tool_choice:none"); + } + if (parsed.options.serviceTier !== undefined) { + throw new Error("Kiro does not support service tiers"); + } + // Structured output is a real contract Kiro cannot honour: the wire has no + // schema-constrained response mode, so a caller expecting parseable JSON would receive + // prose and fail downstream. Refuse it. + // + // The rest of the Responses `text` object is not that. `text.verbosity` is a length + // preference and `text.format: {type:"text"}` is ordinary prose — the default output + // mode, which no capability flag governs and every correct client may send. Testing + // `_rawBody.text !== undefined` refused those turns for the mere PRESENCE of the key, + // the same mistake db040e70f removed one condition earlier where a permissive + // `parallel_tool_calls` hint was read as a requirement. + // + // Nothing needs stripping the way openai-responses strips a no-op verbosity: + // buildKiroPayload composes conversationState field by field from `parsed` and never + // spreads `_rawBody`, so a tolerated control is dropped by construction. The test + // asserts that absence so it stays true. + if (parsed._structuredOutput) { + throw new Error("Kiro does not support Responses structured output"); + } +} + +type KiroTurn = + | { + kind: "user"; + content: string; + images: KiroImage[]; + toolResults: KiroToolResult[]; + /** True only for the proxy-generated acknowledgement after a delivered final answer. */ + answerDeliveredAck?: boolean; + } + | { + kind: "assistant"; + content: string; + toolUses: KiroToolUse[]; + redactedReasoning?: string; + /** A Responses final_answer already shown to the user; this turn must not be resumed. */ + finalAnswer?: boolean; + }; + +function appendTurnText(target: string, next: string): string { + if (!next) return target; + return target ? `${target}\n\n${next}` : next; +} + +function validateKiroConversationState(history: KiroHistoryEntry[], currentMessage: KiroHistoryEntry): void { + const entries = [...history, currentMessage]; + const pendingToolUses = new Set(); + let previousRole: "user" | "assistant" | undefined; + + for (const entry of entries) { + const user = entry.userInputMessage; + const assistant = entry.assistantResponseMessage; + if (Boolean(user) === Boolean(assistant)) { + throw new Error("Kiro conversation entries must contain exactly one message role"); + } + const role = user ? "user" : "assistant"; + if (role === previousRole) throw new Error("Kiro conversation roles must alternate"); + previousRole = role; + + if (user) { + const hasPayload = Boolean(user.content.trim()) + || Boolean(user.images?.length) + || Boolean(user.userInputMessageContext?.toolResults?.length); + if (!hasPayload) throw new Error("Kiro user messages must not be empty"); + for (const result of user.userInputMessageContext?.toolResults ?? []) { + if (!pendingToolUses.delete(result.toolUseId)) { + throw new Error(`Kiro tool result has no matching tool use ${JSON.stringify(result.toolUseId)}`); + } + if (!result.content.some(part => part.text.trim())) { + throw new Error(`Kiro tool result must not be empty ${JSON.stringify(result.toolUseId)}`); + } + } + continue; + } + + const toolUses = assistant?.toolUses ?? []; + if (!assistant?.content.trim() && toolUses.length === 0) { + throw new Error("Kiro assistant messages must not be empty"); + } + for (const toolUse of toolUses) { + if (pendingToolUses.has(toolUse.toolUseId)) { + throw new Error(`Kiro conversation contains duplicate tool use ${JSON.stringify(toolUse.toolUseId)}`); + } + pendingToolUses.add(toolUse.toolUseId); + } + } + if (pendingToolUses.size > 0) throw new Error("Kiro conversation contains an unanswered tool use"); +} + +export function buildKiroPayload( + parsed: OcxParsedRequest, + profileArn: string | undefined, + forcedCompletionMode?: KiroCompletionMode, + wireClient: KiroWireClient = "ide", +): { + payload: Record; + nameMap: Map; + conversationId: string; + completionMode: KiroCompletionMode; +} { + validateKiroCapabilities(parsed); + const modelId = mapModelId(parsed.modelId); + const registry = createKiroToolNameRegistry(); + const toolContext = convertKiroToolContext(parsed, registry); + const ordinaryTools = toolContext.tools; + // A replay that already ends in a delivered final answer has nothing left to complete. Keeping + // the private completion tool enabled here reopens the closed task even if the trailing prompt is + // neutral, because the model is still instructed to produce another terminal answer. + const completionMode = resolveKiroCompletionMode(parsed, ordinaryTools.length, forcedCompletionMode); + const kiroTools = completionMode === "disabled" + ? ordinaryTools + : [...ordinaryTools, kiroCompletionTool()]; + const nameMap = toolContext.nameMap; + const systemParts: string[] = []; + const injectedChars = { value: 0 }; + // Neutralize Codex's GPT-5 identity line so a routed Kiro model never misreports as GPT-5/OpenAI + // and the proxy identity never leaks upstream. + if (parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n"))); + for (const addition of toolContext.systemAdditions) { + const boundedAddition = boundedKiroInjectedInstruction(addition, injectedChars); + if (boundedAddition) systemParts.push(boundedAddition); + } + const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames( + kiroToolWireNames(kiroTools), + undefined, + toolContext.codeModeExecName, + ); + const boundedNudge = toolCatalogNudge ? boundedKiroInjectedInstruction(toolCatalogNudge, injectedChars) : undefined; + if (boundedNudge) systemParts.push(boundedNudge); + if (completionMode !== "disabled") { + const boundedCompletion = boundedKiroInjectedInstruction(KIRO_COMPLETION_INSTRUCTIONS, injectedChars); + if (boundedCompletion) systemParts.push(boundedCompletion); + } + const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : ""; + const turns: KiroTurn[] = []; + const priorCalls = new Map(); + const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => { + const last = turns.at(-1); + if (last?.kind === "user") { + last.content = appendTurnText(last.content, content); + last.images.push(...images); + last.toolResults.push(...toolResults); + } else { + turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] }); + } + }; + const pushAssistant = ( + content: string, + toolUses: KiroToolUse[], + redactedReasoning?: string, + finalAnswer?: boolean, + ): 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; + // Finality follows the LAST merged component. Commentary after a final answer means work + // continued and therefore reopens the turn legitimately. + last.finalAnswer = finalAnswer === true; + } else { + turns.push({ + kind: "assistant", + content, + toolUses: [...toolUses], + ...(redactedReasoning ? { redactedReasoning } : {}), + ...(finalAnswer ? { finalAnswer: true } : {}), + }); + } + }; + + // Codex custom tools may emit several adjacent output items for one invocation (for example + // progress notifications followed by the final value). Kiro accepts one result per tool use, so + // coalesce only immediately adjacent outputs whose ORIGINAL ids are identical. The raw-id check + // is important: normalizeToolId is lossy (`|`, whitespace, truncation), and must never authorize a + // different result merely because two caller-controlled ids normalize to the same wire id. + let adjacentResult: { + rawId: string; + result: KiroToolResult; + texts: string[]; + count: number; + hasImages: boolean; + codeModeExec: boolean; + } | undefined; + const finishAdjacentResult = (): void => { + if (adjacentResult?.codeModeExec) { + const normalized = normalizeCodeModeToolResult(adjacentResult.texts, { + isError: adjacentResult.result.status === "error", + hasImages: adjacentResult.hasImages, + }); + if (normalized) { + adjacentResult.result.content = normalized.map(text => ({ text })); + adjacentResult = undefined; + return; + } + } + if (adjacentResult && adjacentResult.count > 1) { + if (adjacentResult.texts.some(text => text.trim())) { + adjacentResult.result.content = adjacentResult.texts.map(text => ({ text })); + } else if (adjacentResult.hasImages || adjacentResult.result.status === "error") { + adjacentResult.result.content = [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }]; + } + } + adjacentResult = undefined; + }; + + const payloadMessages = kiroPayloadMessages(parsed); + const replayMessagePrefixLength = Math.min( + Math.max(0, parsed._replayMessagePrefixLen ?? 0), + payloadMessages.length, + ); + for (let messageIndex = 0; messageIndex < payloadMessages.length; messageIndex++) { + const msg = payloadMessages[messageIndex]; + const isReplayedMessage = messageIndex < replayMessagePrefixLength; + // Preserve source-message adjacency even when the turn normalization below would collapse or + // skip a structural message. + if (msg.role !== "toolResult") finishAdjacentResult(); + if (msg.role === "user" || msg.role === "developer") { + const text = userContentText((msg as { content: string | OcxContentPart[] }).content); + // Historical text/tool structure remains replayable, but image bytes are scoped to the turn + // that introduced them. Re-sending completed-turn images makes Kiro inspect the same visual on + // every unrelated follow-up and repeatedly pays the multimodal context cost. The parser-owned + // prefix boundary keeps current user/tool-result images intact, including the bounded internal + // completion retry built from this same parsed request. + const images = isReplayedMessage + ? [] + : extractKiroImages((msg as { content: string | OcxContentPart[] }).content); + pushUser(text, images); + } else if (msg.role === "assistant") { + const aMsg = msg as OcxAssistantMessage; + const rawText = (aMsg.content || []) + .filter((b): b is OcxTextContent => b.type === "text") + .map(b => b.text) + .join(""); + // Responses commentary is already-visible UI progress, but it can also carry durable task + // state: decisions, completed steps, rejected hypotheses, and the next action. Preserve it in + // Kiro's explicit history so tool-result continuations and compaction can resume from that + // state. Historical input is never re-emitted by the stream parser; the completion contract + // separately tells Kiro not to repeat or paraphrase an earlier progress update. + const text = rawText; + const toolCalls = (aMsg.content || []) + .filter((b): b is OcxToolCall => b.type === "toolCall"); + const toolUses: KiroToolUse[] = toolCalls.map(tc => { + const toolUseId = normalizeToolId(tc.id); + if (!toolUseId) throw new Error("Kiro history contains a tool call with an empty id"); + if (priorCalls.has(toolUseId)) throw new Error(`Kiro history contains duplicate tool call id ${JSON.stringify(tc.id)}`); + const wireName = namespacedToolName(tc.namespace, tc.name); + const name = registry.alias(wireName); + priorCalls.set(toolUseId, { wireName, rawId: tc.id }); + return { name, input: (tc.arguments ?? {}) as Record, toolUseId }; + }); + if (!text && toolUses.length === 0) { + const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim()); + if (hasReasoning) continue; + } + pushAssistant( + text, + toolUses, + aMsg.kiroRedactedReasoning, + aMsg.phase === "final_answer" && toolUses.length === 0, + ); + } else if (msg.role === "toolResult") { + const tr = msg as OcxToolResultMessage; + if (tr.containsEncryptedContent) { + throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`); + } + const text = userContentText(tr.content); + const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE; + const images = isReplayedMessage ? [] : extractKiroImages(tr.content); + // Retired image bytes still prove the tool produced output; do not diagnose them as a + // missing text() call merely because the fork omits old pixels from continuation history. + const hasImages = typeof tr.content !== "string" && tr.content.some(part => part.type === "image"); + const toolUseId = normalizeToolId(tr.toolCallId); + const call = priorCalls.get(toolUseId); + if (!call || call.rawId !== tr.toolCallId) { + throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`); + } + const last = turns.at(-1); + if ( + adjacentResult?.rawId === tr.toolCallId + && last?.kind === "user" + && last.toolResults.at(-1) === adjacentResult.result + ) { + adjacentResult.count += 1; + adjacentResult.hasImages ||= hasImages; + if (text.length > 0) adjacentResult.texts.push(text); + last.images.push(...images); + if (tr.isError) adjacentResult.result.status = "error"; + continue; + } + finishAdjacentResult(); + // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix. + // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code + // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the + // newest user intent behind boilerplate. Backfill below only when nothing else speaks. + const result: KiroToolResult = { + content: [{ text: resultText }], + status: tr.isError ? "error" : "success", + toolUseId, + }; + pushUser("", images, [result]); + adjacentResult = { + rawId: tr.toolCallId, + result, + texts: text.length > 0 ? [text] : [], + count: 1, + hasImages, + // Ownership comes from the paired call and the emitted freeform catalog, never tr.toolName. + codeModeExec: toolContext.codeModeExecName !== undefined && call.wireName === "exec", + }; + } + } + finishAdjacentResult(); + + if (turns.length === 0 || turns[0].kind === "assistant") { + turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] }); + } + const trailingTurn = turns.at(-1); + if (trailingTurn?.kind === "assistant") { + const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE; + turns.push({ + kind: "user", + content: trailingTurn.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText, + images: [], + toolResults: [], + ...(trailingTurn.finalAnswer ? { answerDeliveredAck: true } : {}), + }); + } + + // Give tool-result turns a carrier sentence ONLY when they carry no other text. This runs + // before the pop below so the current turn is covered too: skipping it there would ship an + // empty current content, which validateKiroConversationState accepts (tool results count as + // payload) and would therefore fail silently. + for (const turn of turns) { + if (turn.kind === "user" && !turn.content.trim() && turn.toolResults.length > 0) { + turn.content = KIRO_TOOL_RESULT_CARRIER_MESSAGE; + } + } + + const currentTurn = turns.pop(); + if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn"); + // Keep internal acknowledgement state separate from its text: a real user may quote the same + // sentence and must still receive ordinary thinking/completion behavior. + const answerDeliveredAck = currentTurn.answerDeliveredAck === true; + const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant" + ? { + assistantResponseMessage: { + content: turn.content, + ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}), + ...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}), + }, + } + : { + userInputMessage: { + content: turn.content, + modelId, + origin: wireClient === "cli" ? "KIRO_CLI" : "AI_EDITOR", + ...(turn.images.length > 0 ? { images: turn.images } : {}), + ...(turn.toolResults.length > 0 ? { userInputMessageContext: { toolResults: turn.toolResults } } : {}), + }, + }; + const history = turns.map(toEntry); + const currentEntry = toEntry(currentTurn); + const currentUim = currentEntry.userInputMessage!; + + if (systemPrefix) { + const firstUser = history.find(e => e.userInputMessage)?.userInputMessage; + if (firstUser) firstUser.content = systemPrefix + firstUser.content; + else currentUim.content = systemPrefix + currentUim.content; + } + if (kiroTools.length > 0) { + currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools }; + } + if (completionMode === "text_fallback") { + if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) { + currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE); + } + } else if ( + !currentUim.userInputMessageContext?.toolResults + && currentUim.content !== KIRO_CONTINUATION_MESSAGE + && !answerDeliveredAck + ) { + currentUim.content = injectKiroThinkingTags(currentUim.content, parsed); + } + + validateKiroConversationState(history, currentEntry); + const conversationId = stableConversationId(parsed); + const payload: Record = { + conversationState: { + chatTriggerType: "MANUAL", + ...(wireClient === "cli" ? { + agentContinuationId: crypto.randomUUID(), + agentTaskType: "vibe", + } : {}), + conversationId, + currentMessage: { userInputMessage: currentUim }, + ...(history.length > 0 ? { history } : {}), + }, + }; + const effort = parsed.options.reasoning; + 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 = { [effortField]: { effort } }; + } + if (profileArn) payload.profileArn = profileArn; + return { payload, nameMap, conversationId, completionMode }; +} + +// Stream parsing (shared by parseStream + parseResponse) +// CodeWhisperer GenerateAssistantResponse ALWAYS returns an AWS eventstream body (there is no +// non-streaming wire mode), so the streaming bridge and non-streaming Responses path decode the +// same way — parseResponse just collects what parseStream yields. +interface KiroAttemptParseResult { + terminal?: AdapterEvent; + needsFallback?: boolean; + usage?: OcxUsage; + providerState?: { kiro: { conversationId: string } }; + assistantText: string; + sawReasoning: boolean; +} + +interface KiroAttemptResult extends KiroAttemptParseResult { + releaseRetained(): void; +} + +interface KiroAttemptRetention { + trackReplacement(previousBytes: number, nextBytes: number): void; + retainEvent(event: AdapterEvent, bytes: number): void; + releaseEvent(event: AdapterEvent): void; + releaseAll(): void; +} + +function createKiroAttemptRetention(budget: TranslatorBudget): KiroAttemptRetention { + let retainedBytes = 0; + const eventBytes = new Map(); + return { + trackReplacement(previousBytes, nextBytes) { + retainedBytes = Math.max(0, retainedBytes - previousBytes) + nextBytes; + }, + retainEvent(event, bytes) { + retainedBytes += bytes; + eventBytes.set(event, bytes); + }, + releaseEvent(event) { + const bytes = eventBytes.get(event); + if (bytes === undefined) return; + eventBytes.delete(event); + retainedBytes = Math.max(0, retainedBytes - bytes); + budget.releaseRetained(bytes, { kind: "retained_collectors" }); + }, + releaseAll() { + if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + retainedBytes = 0; + eventBytes.clear(); + }, + }; +} + +interface KiroFallbackAttempt { + response: Response; + inputTokens: number; + contextInputEstimate: number; + nameMap: Map; + conversationId: string; + releaseRequestBody?: () => void; +} + +function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { + let nextBytes = previousBytes + Buffer.byteLength(fragment); + const previousLast = previous.charCodeAt(previous.length - 1); + const fragmentFirst = fragment.charCodeAt(0); + if (previousLast >= 0xd800 && previousLast <= 0xdbff + && fragmentFirst >= 0xdc00 && fragmentFirst <= 0xdfff) { + nextBytes -= 2; + } + return nextBytes; +} + +interface KiroContextWindowState { + value?: number; +} + +type KiroFallbackFactory = ( + conversationId: string | undefined, + assistantText: string, + sawReasoning: boolean, + budget: TranslatorBudget, +) => Promise; + +function mergeKiroUsage( + first: OcxUsage | undefined, + second: OcxUsage | undefined, + preserveFirstContextGrowth = false, +): OcxUsage | undefined { + if (!first) return second; + if (!second) return first; + const sumOptional = (key: keyof OcxUsage): number | undefined => { + const a = first[key]; + const b = second[key]; + return typeof a === "number" || typeof b === "number" + ? (typeof a === "number" ? a : 0) + (typeof b === "number" ? b : 0) + : undefined; + }; + const totalTokens = typeof first.totalTokens === "number" && typeof second.totalTokens === "number" + ? first.totalTokens + second.totalTokens + : undefined; + const carriedContextTotal = preserveFirstContextGrowth && typeof first.contextTotalTokens === "number" + ? first.contextTotalTokens + second.outputTokens + : undefined; + const combinedOutputTokens = first.outputTokens + second.outputTokens; + return { + inputTokens: first.inputTokens + second.inputTokens, + outputTokens: combinedOutputTokens, + ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number" + ? { + contextTotalTokens: Math.max( + first.contextTotalTokens ?? 0, + second.contextTotalTokens ?? 0, + carriedContextTotal ?? 0, + combinedOutputTokens, + ), + } + : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), + ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}), + ...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}), + ...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}), + ...(first.estimated || second.estimated ? { estimated: true } : {}), + }; +} + +function retryableKiroIncomplete( + reason: string, + message: string, + usage: OcxUsage, + providerState: { kiro: { conversationId: string } } | undefined, + retryable = true, +): AdapterEvent { + return { + type: "incomplete", + reason, + message, + usage, + retryable, + endTurn: false, + ...(providerState ? { providerState } : {}), + }; +} + +/** + * Catch-path retryability for #519: only transport/socket failures with no emitted output + * are replay-safe. Malformed event payloads (`invalid Kiro …`) and any post-output failure + * stay terminal — same spirit as cursor's emittedOutput gate. + */ +export function isRetryableKiroStreamCatchError(err: unknown, emittedOutput: boolean): boolean { + if (emittedOutput) return false; + const message = err instanceof Error ? err.message : String(err); + if (/^invalid Kiro\b/i.test(message)) return false; + // Include Smithy/eventstream truncation (`eventstream: truncated message at end of stream`): + // partial frame + clean EOF with zero output is the same replay-safe class as a socket close. + return /socket connection was closed|connection(?: was)? closed unexpectedly|ECONNRESET|EPIPE|UND_ERR_|fetch failed|decoder failed|premature close|other side closed|unexpected EOF|network connection lost|terminated|truncated message at end of stream|eventstream:\s*truncated/i + .test(message); +} + +/** Native clean-stop reason eligible for bounded private-completion validation. */ +const KIRO_END_TURN_STOP_REASON = "END_TURN"; + +async function* parseKiroAttempt( + response: Response, + budget: TranslatorBudget, + mode: KiroCompletionMode, + modelId: string | undefined, + inputTokens: number, + contextWindowState: KiroContextWindowState, + nameMap: Map | undefined, + conversationId: string | undefined, + contextInputEstimate?: number, + /** True when an earlier attempt already flushed visible content to the client (#520). */ + priorEmittedOutput = false, + onTransientThrottle?: () => void, +): AsyncGenerator { + // `required` mode holds staged commentary until a real tool call or terminal metadata identifies + // the attempt boundary. Anything the inner parser leaves behind is flushed before the terminal. + const deferred: AdapterEvent[] = []; + const retention = createKiroAttemptRetention(budget); + // The inner parser can observe Kiro's authoritative context checkpoint, but only this wrapper + // knows whether the attempt is terminal or will be followed by the bounded completion retry. + const attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } } = {}; + const attempt = parseKiroAttemptEvents( + response, + budget, + mode, + modelId, + inputTokens, + contextWindowState, + nameMap, + conversationId, + deferred, + retention, + attemptCalibration, + contextInputEstimate, + priorEmittedOutput, + onTransientThrottle, + ); + let handedOff = false; + try { + const result = yield* attempt; + const stagedCalibration = attemptCalibration.value; + attemptCalibration.value = undefined; + if (stagedCalibration && !result.needsFallback) { + recordKiroCalibration( + stagedCalibration.conversationId, + stagedCalibration.estimated, + stagedCalibration.charged, + ); + } + for (const event of deferred.splice(0)) { + try { yield event; } finally { retention.releaseEvent(event); } + } + handedOff = true; + return { ...result, releaseRetained: () => retention.releaseAll() }; + } finally { + if (!handedOff) retention.releaseAll(); + } +} + +async function* parseKiroAttemptEvents( + response: Response, + budget: TranslatorBudget, + mode: KiroCompletionMode, + modelId: string | undefined, + inputTokens: number, + contextWindowState: KiroContextWindowState, + nameMap: Map | undefined, + conversationId: string | undefined, + deferred: AdapterEvent[], + retention: KiroAttemptRetention, + attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } }, + contextInputEstimate?: number, + priorEmittedOutput = false, + onTransientThrottle?: () => void, +): AsyncGenerator { + const emptyResult = (): KiroAttemptParseResult => ({ assistantText: "", sawReasoning: false }); + if (!response.body) { + return { + ...emptyResult(), + terminal: { type: "error", message: "Kiro response has no body", status: 502, errorType: "upstream_error" }, + }; + } + + let open: { id: string; name: string; chunks: string[]; completion: boolean } | null = null; + let openCallId: string | undefined; + const closeOpenCall = () => { + if (!openCallId) return; + budget.closeCall(openCallId); + openCallId = undefined; + }; + let outputChars = ""; + let outputCharsBytes = 0; + let contextUsagePercentage: number | undefined; + let returnedConversationId = conversationId; + let assistantText = ""; + let assistantTextBytes = 0; + let sawText = false; + let sawReasoning = false; + let sawRealTool = false; + let completionAnswer: string | undefined; + let completionCalls = 0; + let authoritativeUsage: OcxUsage | undefined; + let stopReason: string | undefined; + const fallbackEvents: AdapterEvent[] = []; + const thinking = new KiroThinkingParser(budget); + + const retainedEventBytes = (event: AdapterEvent): number => Buffer.byteLength(JSON.stringify(event)); + const retainEvent = (event: AdapterEvent): void => { + const bytes = retainedEventBytes(event); + budget.chargeRetained(bytes, { kind: "retained_collectors" }); + retention.retainEvent(event, bytes); + }; + const emitRetained = async function* (events: Iterable): AsyncGenerator { + for (const event of events) { + try { yield event; } finally { retention.releaseEvent(event); } + } + }; + // A valid private completion supersedes prose staged during the SAME inference. Kiro sometimes + // emits answer-shaped text and then calls the terminal tool; forwarding both makes Codex render + // two near-identical assistant messages. Drop only staged text on this proven completion path, + // preserve non-text events, and release every retained event either way. + const consumeSupersededByCompletion = async function* ( + events: AdapterEvent[], + ): AsyncGenerator { + for (const event of events.splice(0)) { + try { + if (event.type !== "text_delta") yield event; + } finally { + retention.releaseEvent(event); + } + } + }; + + const providerState = (): { kiro: { conversationId: string } } | undefined => + returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; + + const contextUsageTotalFloor = (): number | undefined => { + if (contextUsagePercentage === undefined || !contextWindowState.value) return undefined; + const floor = Math.ceil(contextWindowState.value * Math.min(contextUsagePercentage, 100) / 100); + return Number.isFinite(floor) && floor > 0 ? floor : undefined; + }; + const usage = (): OcxUsage => { + const base = authoritativeUsage ?? { + inputTokens, + outputTokens: estimateKiroTokens(outputChars, modelId), + estimated: true, + }; + const estimatedContextTotal = contextInputEstimate !== undefined + ? contextInputEstimate + base.outputTokens + : undefined; + const authoritativeTurnTotal = base.inputTokens + base.outputTokens; + const contextTotal = Math.max( + estimatedContextTotal ?? 0, + contextUsageTotalFloor() ?? 0, + authoritativeTurnTotal, + ); + return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base; + }; + + const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => { + // Upstream exception/error frames can arrive after commentary was already staged (and will be + // flushed before this terminal is yielded). Replaying after that content would duplicate it. + const emittedOutput = priorEmittedOutput + || sawText + || sawReasoning + || sawRealTool + || assistantText.length > 0 + || deferred.length > 0 + || completionAnswer !== undefined + || completionCalls > 0 + || open !== null + || fallbackEvents.length > 0; + if (failure.status === 429 && failure.retryable) onTransientThrottle?.(); + return { + type: "error", + message: failure.message, + status: failure.status, + errorType: failure.errorType, + code: failure.code, + retryable: emittedOutput ? false : failure.retryable, + usage: usage(), + }; + }; + + const protocolTerminal = (message: string, malformedCompletion = false): AdapterEvent => { + if (mode === "text_fallback" && malformedCompletion) { + return retryableKiroIncomplete( + "malformed_kiro_completion", + message, + usage(), + providerState(), + // First-attempt progress was already flushed before this bounded fallback (#520). + !priorEmittedOutput, + ); + } + return { + type: "error", + message, + status: 502, + errorType: "upstream_error", + code: malformedCompletion ? "invalid_kiro_completion" : "kiro_stream_protocol_error", + retryable: false, + usage: usage(), + }; + }; + + const classifyTool = ( + tool: { id: string; name: string; chunks: string[]; completion: boolean }, + ): AdapterEvent | undefined => { + if (tool.name !== KIRO_COMPLETION_TOOL_NAME) { + tool.completion = false; + return completionAnswer !== undefined || completionCalls > 0 + ? protocolTerminal("Kiro returned a real tool call alongside a private final answer") + : undefined; + } + if (mode === "disabled") { + return protocolTerminal("Kiro returned the reserved private final-answer tool while explicit completion was disabled"); + } + tool.completion = true; + if (completionAnswer !== undefined || completionCalls > 0) { + return protocolTerminal("Kiro returned more than one private final-answer tool call", true); + } + if (sawRealTool) { + return protocolTerminal("Kiro returned a private final answer alongside a real tool call"); + } + return undefined; + }; + + const beginTool = ( + id: string, + name: string, + ): { tool?: { id: string; name: string; chunks: string[]; completion: boolean }; terminal?: AdapterEvent } => { + const next = { id, name, chunks: [], completion: false }; + const terminal = classifyTool(next); + 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); + retainEvent(event); + return [{ type: "heartbeat" }]; + }; + + const stage = (event: AdapterEvent): AdapterEvent[] => { + if (event.type === "text_delta") { + const nextAssistantTextBytes = appendedUtf8Bytes(assistantText, assistantTextBytes, event.text); + const assistantReservation = budget.reserveTransient(nextAssistantTextBytes, { kind: "retained_collectors" }); + assistantText += event.text; + assistantReservation.commitRetained(); + budget.releaseRetained(assistantTextBytes, { kind: "retained_collectors" }); + retention.trackReplacement(assistantTextBytes, nextAssistantTextBytes); + assistantTextBytes = nextAssistantTextBytes; + if (event.text.trim()) sawText = true; + const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, event.text); + const outputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); + outputChars += event.text; + outputReservation.commitRetained(); + budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); + retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); + outputCharsBytes = nextOutputCharsBytes; + const phased = mode === "disabled" + ? event + : { ...event, phase: "commentary" as const }; + if (mode === "text_fallback") { + fallbackEvents.push(phased); + retainEvent(phased); + return []; + } + 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; + if (text.trim()) sawReasoning = true; + const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, text); + const reasoningReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); + outputChars += text; + reasoningReservation.commitRetained(); + budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); + retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); + outputCharsBytes = nextOutputCharsBytes; + } + if (mode === "text_fallback" && event.type !== "heartbeat") { + fallbackEvents.push(event); + retainEvent(event); + return []; + } + return mode === "required" ? defer(event) : [event]; + }; + + const parseCompletion = (chunks: string[]): string | Error => { + const raw = chunks.join("").trim(); + let value: unknown; + try { + value = JSON.parse(raw || "{}"); + } catch { + return new Error("Kiro returned invalid JSON for the private final-answer tool"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return new Error("Kiro returned a non-object value for the private final-answer tool"); + } + const answer = (value as { answer?: unknown }).answer; + if (typeof answer !== "string" || !answer.trim()) { + return new Error("Kiro returned an empty final answer"); + } + return answer; + }; + + const flushOpen = (): { events: AdapterEvent[]; terminal?: AdapterEvent } => { + if (!open) return { events: [] }; + const tool = open; + open = null; + closeOpenCall(); + const input = tool.chunks.join(""); + if (!isCompleteKiroToolInput(input)) { + return { events: [], terminal: protocolTerminal(kiroTruncationErrorMessage("incomplete tool input JSON"), tool.completion) }; + } + if (tool.completion) { + completionCalls++; + if (completionCalls > 1) { + return { events: [], terminal: protocolTerminal("Kiro returned more than one private final-answer tool call", true) }; + } + if (sawRealTool) { + return { events: [], terminal: protocolTerminal("Kiro returned a private final answer alongside a real tool call") }; + } + const answer = parseCompletion(tool.chunks); + if (answer instanceof Error) return { events: [], terminal: protocolTerminal(answer.message, true) }; + completionAnswer = answer; + return { events: [] }; + } + if (completionAnswer !== undefined || completionCalls > 0) { + return { events: [], terminal: protocolTerminal("Kiro returned a real tool call alongside a private final answer") }; + } + sawRealTool = true; + const restored = nameMap?.get(tool.name) ?? tool.name; + return { + events: [ + { type: "tool_call_start", id: tool.id, name: restored }, + ...tool.chunks.filter(Boolean).map(argumentsChunk => ({ type: "tool_call_delta", arguments: argumentsChunk }) as AdapterEvent), + { type: "tool_call_end" }, + ], + }; + }; + + try { + for await (const msg of decodeEventStream(response.body)) { + const mt = msg.headers[":message-type"]; + if (mt === "exception" || mt === "error") { + open = null; + return { + assistantText, + sawReasoning, + terminal: classifiedTerminal(classifyKiroStreamError(msg.headers, new TextDecoder().decode(msg.payload))), + }; + } + if (mt !== "event") { + open = null; + return { + assistantText, + sawReasoning, + terminal: protocolTerminal(`Kiro response protocol error: unsupported Smithy message type ${JSON.stringify(mt ?? "missing")}`), + }; + } + const eventType = msg.headers[":event-type"]; + if (!eventType) { + open = null; + return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: event is missing :event-type") }; + } + const ev = parseKiroEvent(eventType, msg.payload); + if (!ev) continue; + switch (ev.type) { + case "metadata": + if (ev.usage) authoritativeUsage = ev.usage; + 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)) { + rekeyKiroCalibration(returnedConversationId, ev.conversationId); + returnedConversationId = ev.conversationId; + } + break; + case "content": + if (ev.modelId) { + contextWindowState.value = kiroUpstreamContextWindow(ev.modelId) ?? contextWindowState.value; + } + if (open) { + open = null; + return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("content arrived before tool stop")) }; + } + if (ev.data) { + for (const contentEvent of thinking.feed(ev.data)) { + yield* emitRetained(stage(contentEvent)); + } + } + break; + case "reasoning": + for (const contentEvent of thinking.flush()) { + yield* emitRetained(stage(contentEvent)); + } + 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()) { + yield* emitRetained(stage(contentEvent)); + } + if (!open) { + if (ev.stop === true) { + return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: tool stop received without an open tool call") }; + } + if (!ev.toolUseId || !ev.name) { + return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: new tool event is missing toolUseId or name") }; + } + const started = beginTool(ev.toolUseId, ev.name); + if (started.terminal) return { assistantText, sawReasoning, terminal: started.terminal }; + open = started.tool!; + budget.openCall(open.id); + openCallId = open.id; + } else if ( + (ev.toolUseId && ev.toolUseId !== open.id) + || (ev.name && open.name !== "unknown" && ev.name !== open.name) + ) { + closeOpenCall(); + open = null; + return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("tool input changed identity before stop")) }; + } + if (open && open.name === "unknown" && ev.name) { + open.name = ev.name; + const terminal = classifyTool(open); + if (terminal) { + open = null; + return { assistantText, sawReasoning, terminal }; + } + } + if (open && ev.input !== undefined) { + const previousCallBytes = open.chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk), 0); + const nextCallBytes = previousCallBytes + Buffer.byteLength(ev.input); + const callReservation = budget.reserveTransient(nextCallBytes, { kind: "tool_args", callId: open.id }); + open.chunks.push(ev.input); + callReservation.commitRetained(); + budget.releaseRetained(previousCallBytes, { kind: "tool_args", callId: open.id }); + const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, ev.input); + const toolOutputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); + outputChars += ev.input; + toolOutputReservation.commitRetained(); + budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); + retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); + outputCharsBytes = nextOutputCharsBytes; + } + if (ev.stop === true) { + const flushed = flushOpen(); + if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; + for (const event of flushed.events) { + yield* emitRetained(stage(event)); + } + } else { + yield { type: "heartbeat" }; + } + break; + } + case "invalid_state": + open = null; + return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(undefined, ev.message ?? "Kiro entered an invalid state")) }; + case "error": + open = null; + return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(ev.reason, ev.message)) }; + case "truncation": + open = null; + return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage(ev.data)) }; + } + } + + for (const contentEvent of thinking.flush()) { + yield* emitRetained(stage(contentEvent)); + } + if (open) { + const input = open.chunks.join(""); + if (!isCompleteKiroToolInput(input)) { + const privateTool = open.completion; + open = null; + return { + assistantText, + sawReasoning, + terminal: protocolTerminal(kiroTruncationErrorMessage("stream ended before tool stop"), privateTool), + }; + } + const flushed = flushOpen(); + if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; + for (const event of flushed.events) { + yield* emitRetained(stage(event)); + } + } + + const finalUsage = usage(); + const finalProviderState = providerState(); + if (contextUsagePercentage !== undefined) { + debugProviderDiagnostic("kiro", "context_usage", { + contextUsagePercentage, + ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}), + }); + } + // The percentage is an absolute post-response checkpoint. Remove generated output before + // comparing it with the request-only estimate, then stage the observation for the outer parser + // to commit only if this attempt is terminal (not the first half of a bounded fallback). + const chargedTotal = contextUsageTotalFloor(); + if (chargedTotal !== undefined && contextInputEstimate !== undefined) { + const chargedInput = chargedTotal - finalUsage.outputTokens; + if (chargedInput > 0 && returnedConversationId) { + attemptCalibration.value = { + conversationId: returnedConversationId, + estimated: contextInputEstimate, + charged: chargedInput, + }; + } + } + // Native stop metadata proves that this inference ended, but it does not prove that ordinary + // text is a final answer. Kiro has emitted END_TURN for progress prose, so tool-enabled turns + // still require the private completion call to distinguish commentary from completion (#531). + const normalizedStopReason = stopReason?.trim().toUpperCase(); + const nativeCompletionStop = (normalizedStopReason === KIRO_END_TURN_STOP_REASON + || normalizedStopReason === "STOP_SEQUENCE") + && sawText + && !sawRealTool + && completionAnswer === undefined + && completionCalls === 0; + + debugProviderDiagnostic("kiro", "attempt_complete", { + mode, + sawText, + sawReasoning, + sawRealTool, + completionCalls, + nativeCompletionStop, + ...(stopReason !== undefined ? { stopReason } : {}), + assistantChars: assistantText.length, + }); + + if (mode === "required") { + if (completionAnswer !== undefined) yield* consumeSupersededByCompletion(deferred); + else yield* emitRetained(deferred.splice(0)); + } + + if (mode === "text_fallback") { + if (completionAnswer !== undefined) { + yield* consumeSupersededByCompletion(fallbackEvents); + yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + if (sawRealTool) { + yield* emitRetained(fallbackEvents); + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + if (sawText) { + for (const event of fallbackEvents) { + try { + if (event.type !== "text_delta") yield event; + else yield { ...event, phase: "final_answer" }; + } finally { + retention.releaseEvent(event); + } + } + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + yield* emitRetained(fallbackEvents); + return { + assistantText, + sawReasoning, + terminal: retryableKiroIncomplete( + sawReasoning ? "reasoning_only_kiro_fallback" : "empty_kiro_fallback", + sawReasoning + ? "Kiro produced reasoning but no final answer on its bounded completion retry" + : "Kiro produced no final answer on its bounded completion retry", + finalUsage, + finalProviderState, + // First-attempt progress was already flushed before this bounded fallback (#520). + !priorEmittedOutput, + ), + }; + } + + if (completionAnswer !== undefined) { + yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + if (sawRealTool) { + return { + assistantText, + sawReasoning, + terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, + }; + } + if (mode === "required" && nativeCompletionStop) { + return { + assistantText, + sawReasoning, + needsFallback: true, + usage: finalUsage, + providerState: finalProviderState, + }; + } + + // An explicit non-completion stop reason has already terminated this inference. Converting it into + // another model request would hide truncation behind a second paid call, and for context + // exhaustion it would resubmit a request that cannot fit. Only a MISSING stop reason falls + // through to the bounded compatibility fallback below. + // + // END_TURN and STOP_SEQUENCE with text take the bounded validation path above; reaching here + // with either means the turn produced no replayable text. + if (mode === "required" && normalizedStopReason !== undefined) { + const providerStateField = finalProviderState ? { providerState: finalProviderState } : {}; + const incomplete = (reason: string, retryable: boolean) => ({ + assistantText, + sawReasoning, + terminal: { + type: "incomplete" as const, + reason, + message: `Kiro stopped with ${normalizedStopReason} before an explicit final answer`, + usage: finalUsage, + retryable, + endTurn: false, + ...providerStateField, + }, + }); + + if (normalizedStopReason === "MODEL_CONTEXT_WINDOW_EXCEEDED") { + // Reuse the existing context-length contract (kiro-errors.ts) instead of inventing an + // incomplete reason: an unrecognized incomplete becomes a retryable 529 in Claude + // outbound, and `max_output_tokens` would make responses/state.ts cache this partial + // for continuation replay. Both invite a retry that cannot succeed. + return { + assistantText, + sawReasoning, + terminal: { + type: "error" as const, + message: "Kiro stopped because the model context window was exhausted", + status: 400, + errorType: "invalid_request_error", + code: "context_length_exceeded", + retryable: false, + usage: finalUsage, + }, + }; + } + if (normalizedStopReason === "MAX_TOKENS") return incomplete("max_output_tokens", true); + if (normalizedStopReason === "CONTENT_FILTERED" || normalizedStopReason === "GUARDRAIL_INTERVENED") { + return incomplete("content_filter", false); + } + if (normalizedStopReason === "MALFORMED_TOOL_USE") return incomplete("kiro_malformed_tool_use", false); + if (normalizedStopReason === "MALFORMED_MODEL_OUTPUT") return incomplete("kiro_malformed_model_output", false); + // TOOL_USE here means Kiro claimed a tool call it never emitted. + if (normalizedStopReason === "TOOL_USE") return incomplete("kiro_tool_use_without_call", false); + if (normalizedStopReason === KIRO_END_TURN_STOP_REASON || normalizedStopReason === "STOP_SEQUENCE") { + return incomplete(`kiro_${normalizedStopReason.toLowerCase()}_without_text`, false); + } + return incomplete(`kiro_${normalizedStopReason.toLowerCase() || "unknown_stop"}`, false); + } + // Kiro text has no trustworthy final/progress marker. When completion is required, ordinary + // text and reasoning remain unfinished until the one bounded fallback validates the turn. + if (mode === "required" && (sawText || sawReasoning)) { + return { assistantText, sawReasoning, needsFallback: true, usage: finalUsage, providerState: finalProviderState }; + } + if (!sawText && !sawReasoning) { + return { + assistantText, + sawReasoning, + terminal: retryableKiroIncomplete( + "empty_kiro_stream", + "Kiro returned a successful but empty response stream", + finalUsage, + finalProviderState, + ), + }; + } + return { + assistantText, + sawReasoning, + terminal: { + type: "done", + usage: finalUsage, + endTurn: mode === "disabled" ? sawText : false, + ...(finalProviderState ? { providerState: finalProviderState } : {}), + }, + }; + } catch (err) { + if (isTranslatorBudgetExceededError(err)) { + closeOpenCall(); + return { + assistantText, + sawReasoning, + terminal: { + type: "error", + status: 502, + errorType: "upstream_error", + code: "translation_buffer_limit", + message: "upstream translation buffer exceeded the safe limit", + }, + }; + } + // Mid-stream socket closes after response.created / heartbeats only must stay retryable: + // nothing was relayed to the client, so a string-body replay is safe (see #519 / cursor's + // emittedOutput gate). Once any assistant text, reasoning, tool, or deferred content exists + // — including content flushed by a prior attempt before a bounded fallback — fail closed; + // the client may already have partial output. Protocol parse throws stay non-retryable even + // with zero output. + const emittedOutput = priorEmittedOutput + || sawText + || sawReasoning + || sawRealTool + || assistantText.length > 0 + || deferred.length > 0 + || completionAnswer !== undefined + || completionCalls > 0 + || open !== null + || fallbackEvents.length > 0; + return { + assistantText, + sawReasoning, + terminal: { + type: "error", + message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)), + status: 502, + errorType: "server_error", + code: "kiro_stream_protocol_error", + retryable: isRetryableKiroStreamCatchError(err, emittedOutput), + usage: usage(), + }, + }; + } finally { + thinking.dispose(); + closeOpenCall(); + } +} + +export async function* parseKiroStream( + response: Response, + budget: TranslatorBudget, + modelId?: string, + inputTokens = 0, + contextWindow?: number, + nameMap?: Map, + conversationId?: string, + completionMode: KiroCompletionMode = "disabled", + fallbackFactory?: KiroFallbackFactory, + contextInputEstimate?: number, + onTransientThrottle?: () => void, +): AsyncGenerator { + const contextWindowState: KiroContextWindowState = { value: contextWindow }; + const firstResult = yield* parseKiroAttempt( + response, + budget, + completionMode, + modelId, + inputTokens, + contextWindowState, + nameMap, + conversationId, + contextInputEstimate, + false, + onTransientThrottle, + ); + try { + if (!firstResult.needsFallback) { + if (firstResult.terminal) yield firstResult.terminal; + return; + } + if (!fallbackFactory) { + yield retryableKiroIncomplete( + "uncompleted_kiro_response", + "Kiro produced progress without an explicit final answer and no bounded retry transport was available", + firstResult.usage ?? { inputTokens, outputTokens: 0, estimated: true }, + firstResult.providerState, + ); + return; + } + + yield { type: "heartbeat" }; + // First attempt already flushed deferred progress before this point. Gate fallback + // setup/HTTP failures the same way as the second-stream catch so a replay cannot + // duplicate visible commentary (#520). + const priorEmittedOutput = Boolean(firstResult.assistantText.trim()) || firstResult.sawReasoning; + let firstAssistantText = firstResult.assistantText; + const firstHadAssistantText = firstAssistantText.length > 0; + let fallback: KiroFallbackAttempt; + try { + fallback = await fallbackFactory( + firstResult.providerState?.kiro.conversationId ?? conversationId, + firstAssistantText, + firstResult.sawReasoning, + budget, + ); + } catch (err) { + firstAssistantText = ""; + firstResult.assistantText = ""; + firstResult.releaseRetained(); + if (isTranslatorBudgetExceededError(err)) { + yield { + type: "error", + message: "upstream translation buffer exceeded the safe limit", + status: 502, + errorType: "upstream_error", + code: "translation_buffer_limit", + usage: firstResult.usage, + }; + return; + } + yield { + type: "error", + message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)), + status: err instanceof Error && err.name === "TimeoutError" ? 504 : 502, + errorType: "upstream_error", + retryable: !priorEmittedOutput, + usage: firstResult.usage, + }; + return; + } + // The factory has finished using the live first-attempt alias and has retained its own retry + // serialization through the fetch boundary. The discarded parser collectors can now release + // before the second attempt begins on the same turn budget. + firstAssistantText = ""; + firstResult.assistantText = ""; + firstResult.releaseRetained(); + fallback.releaseRequestBody?.(); + if (!fallback.response.ok) { + const payload = await fallback.response.text().catch(() => ""); + const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload); + yield { + type: "error", + message: failure.message, + status: failure.status, + errorType: failure.errorType, + code: failure.code, + retryable: priorEmittedOutput ? false : failure.retryable, + usage: firstResult.usage, + }; + return; + } + + const secondResult = yield* parseKiroAttempt( + fallback.response, + budget, + "text_fallback", + modelId, + fallback.inputTokens, + contextWindowState, + fallback.nameMap, + fallback.conversationId, + fallback.contextInputEstimate, + // First attempt already flushed deferred progress to the client before this fallback. + // A zero-output transport failure here must stay non-retryable to avoid duplicating that text. + priorEmittedOutput, + onTransientThrottle, + ); + try { + if (!secondResult.terminal) { + yield retryableKiroIncomplete( + "empty_kiro_fallback", + "Kiro's bounded completion retry ended without a terminal result", + mergeKiroUsage(firstResult.usage, secondResult.usage, firstHadAssistantText) + ?? { inputTokens, outputTokens: 0, estimated: true }, + secondResult.providerState ?? firstResult.providerState, + !priorEmittedOutput, + ); + return; + } + if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") { + yield { + ...secondResult.terminal, + // Belt-and-suspenders: never advertise a replay-safe incomplete after flushed progress. + ...(secondResult.terminal.type === "incomplete" && priorEmittedOutput + ? { retryable: false as const } + : {}), + usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText), + providerState: secondResult.terminal.providerState ?? firstResult.providerState, + }; + return; + } + yield { + ...secondResult.terminal, + ...(secondResult.terminal.type === "error" + ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText) } + : {}), + }; + } finally { + secondResult.releaseRetained(); + } + } finally { + firstResult.releaseRetained(); + } +} diff --git a/src/adapters/kiro-continuity.ts b/src/adapters/kiro-continuity.ts new file mode 100644 index 0000000000..7c76391809 --- /dev/null +++ b/src/adapters/kiro-continuity.ts @@ -0,0 +1,130 @@ +import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination"; +import type { OcxAssistantMessage, OcxMessage, OcxParsedRequest } from "../types"; +import { + KIRO_COMPLETION_TOOL_NAME, + MAX_KIRO_INJECTED_INSTRUCTION_CHARS, + type KiroCompletionMode, +} from "./kiro-constants"; + +/** Serialization headroom for the one adapter-owned completion-validation replay. */ +export const KIRO_FALLBACK_SERIALIZATION_ENVELOPE_BYTES = 64 * 1024; + +/** True only when no later user/tool-result work follows the delivered final answer. */ +export function hasTrailingDeliveredFinalAnswer( + messages: readonly OcxMessage[], + parsed?: OcxParsedRequest, +): boolean { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") return false; + const assistant = message as OcxAssistantMessage; + if ((assistant.content ?? []).some(part => part.type === "toolCall")) return false; + const hasText = (assistant.content ?? []).some(part => part.type === "text" && part.text.trim()); + if (!hasText) continue; + return assistant.phase === "final_answer" + || (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages)); + } + return false; +} + +/** Decide whether Kiro must use the private terminal completion channel for this turn. */ +export function resolveKiroCompletionMode( + parsed: OcxParsedRequest, + ordinaryToolCount: number, + forcedCompletionMode?: KiroCompletionMode, +): KiroCompletionMode { + if (forcedCompletionMode) return forcedCompletionMode; + if (ordinaryToolCount === 0) return "disabled"; + return hasTrailingDeliveredFinalAnswer(parsed.context.messages, parsed) ? "disabled" : "required"; +} + +export function boundedKiroInjectedInstruction(text: string, used: { value: number }): string | undefined { + const remaining = MAX_KIRO_INJECTED_INSTRUCTION_CHARS - used.value; + if (remaining <= 0 || !text) return undefined; + let result = text.length <= remaining ? text : text.slice(0, remaining); + // Never end the slice on a lone high surrogate: encoding it substitutes U+FFFD. + if (result.length > 0) { + const last = result.charCodeAt(result.length - 1); + if (last >= 0xd800 && last <= 0xdbff) result = result.slice(0, -1); + } + used.value += result.length; + return result.length > 0 ? result : undefined; +} + +/** Test-only stable facade for the injected-instruction bound. */ +export function boundedInjectedInstructionForTests( + text: string, + used: { value: number }, +): string | undefined { + return boundedKiroInjectedInstruction(text, used); +} + +/** Provider-private terminal tool. It is policy, not an ordinary work-tool codec detail. */ +export function kiroCompletionTool(): Record { + return { + toolSpecification: { + name: KIRO_COMPLETION_TOOL_NAME, + description: "Terminal completion channel, not an ordinary work tool. When the task is fully complete and no more work or tool calls are needed, you must call this tool exactly once instead of providing the final answer as ordinary assistant text. Call it the same way when you cannot continue until the user supplies a decision, information, or a clarification that only they can give: the question itself is the answer. Put the complete user-facing final answer in `answer`. The call is complete when issued: it ends the turn, returns no tool result, and no text or tool call may follow it.", + inputSchema: { + json: { + type: "object", + properties: { + answer: { + type: "string", + description: "The complete final answer to show the user, or the blocking question you need the user to answer before you can continue.", + }, + }, + required: ["answer"], + }, + }, + }, + }; +} + +/** + * Prepare the canonical history used by the single bounded completion-validation attempt. + * This contains no native Kiro/AWS fields and deliberately preserves visible assistant progress. + */ +export function prepareKiroCompletionRetry( + requestSnapshot: OcxParsedRequest, + returnedConversationId: string | undefined, + assistantText: string, +): OcxParsedRequest { + const retryParsed = structuredClone(requestSnapshot); + retryParsed._providerContinuation = { + ...(retryParsed._providerContinuation ?? {}), + ...(returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : {}), + }; + // Reasoning is not replayable on the Kiro wire. Only visible text earns a replay turn. + if (assistantText.trim()) { + retryParsed.context.messages.push({ + role: "assistant", + content: [{ type: "text" as const, text: assistantText }], + model: retryParsed.modelId, + timestamp: Date.now(), + }); + } + return retryParsed; +} + +/** Exact UTF-8 size JSON.stringify() will use for a string, without materializing that copy. */ +export function jsonStringSerializedUtf8Bytes(value: string): number { + let bytes = 2; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) bytes += 2; + else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) bytes += 2; + else if (code < 0x20) bytes += 6; + else if (code <= 0x7f) bytes += 1; + else if (code <= 0x7ff) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else bytes += 6; + } else if (code >= 0xdc00 && code <= 0xdfff) bytes += 6; + else bytes += 3; + } + return bytes; +} diff --git a/src/adapters/kiro-transport.ts b/src/adapters/kiro-transport.ts new file mode 100644 index 0000000000..1659e2ae4d --- /dev/null +++ b/src/adapters/kiro-transport.ts @@ -0,0 +1,153 @@ +import { debugProviderDiagnostic } from "../lib/debug"; +import { resolveKiroApiRegion, resolveKiroRequestProfile } from "../oauth/kiro"; +import type { OcxParsedRequest, OcxProviderConfig } from "../types"; +import type { AdapterFetchContext, AdapterRequest } from "./base"; +import { calibrateKiroEstimate } from "./kiro-calibration"; +import { + buildKiroPayload, + estimateKiroInputTokens, + estimateKiroLogInputTokens, + estimateKiroPayloadInputTokens, + kiroPayloadMessages, + type KiroWireClient, +} from "./kiro-codec"; +import type { KiroCompletionMode } from "./kiro-constants"; +import { normalizeKiroImages } from "./kiro-images"; +import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry"; +import { fingerprint, invocationId, osTag } from "./kiro-wire"; + +const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; +const SDK_VERSION = "1.0.27"; +const NODE_VERSION = "22.21.1"; +const KIRO_IDE_VERSION = "1.0.0"; + +export interface KiroNativeBuildResult { + request: AdapterRequest; + nameMap: Map; + conversationId: string; + completionMode: KiroCompletionMode; + inputTokens: number; + contextInputEstimate: number; +} + +function kiroCliPlatform(): "linux" | "macos" | "windows" { + return process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; +} + +function kiroCliUserAgent(includeAppVersion: boolean): string { + return [ + "aws-sdk-rust/1.3.15", + "ua/2.1", + "api/codewhispererstreaming/0.1.17975", + `os/${kiroCliPlatform()}`, + "lang/rust/1.92.0", + ...(includeAppVersion ? ["md/appVersion-2.14.2"] : []), + "m/F", + "app/AmazonQ-For-CLI", + ].join(" "); +} + +function kiroRuntimeEndpoint(provider: OcxProviderConfig, region: string): string { + const configured = new URL(provider.baseUrl); + if ( + /^runtime\.[a-z]{2}(?:-[a-z]+)+-\d\.kiro\.dev$/i.test(configured.hostname) + && configured.pathname === "/" + ) { + return `https://runtime.${region}.kiro.dev/`; + } + return configured.toString(); +} + +function kiroNativeHeaders( + provider: OcxProviderConfig, + wireClient: KiroWireClient, + isApiKey: boolean, + profileArn: string | undefined, +): Record { + const fp = fingerprint().slice(0, 64); + const headers: Record = wireClient === "cli" ? { + authorization: `Bearer ${provider.apiKey}`, + "content-type": "application/x-amz-json-1.0", + accept: "*/*", + "x-amz-target": AMZ_TARGET, + "user-agent": kiroCliUserAgent(true), + "x-amz-user-agent": kiroCliUserAgent(false), + "x-amzn-codewhisperer-optout": "true", + "amz-sdk-request": "attempt=1; max=3", + "amz-sdk-invocation-id": invocationId(), + ...(isApiKey ? { tokentype: "API_KEY" } : {}), + } : { + authorization: `Bearer ${provider.apiKey}`, + "content-type": "application/x-amz-json-1.0", + accept: "application/vnd.amazon.eventstream", + "x-amz-target": AMZ_TARGET, + "user-agent": `aws-sdk-js/${SDK_VERSION} ua/2.1 os/${osTag()} lang/js md/nodejs#${NODE_VERSION} api/codewhispererstreaming#${SDK_VERSION} m/E KiroIDE-${KIRO_IDE_VERSION}-${fp}`, + "x-amz-user-agent": `aws-sdk-js/${SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${fp}`, + "x-amzn-codewhisperer-optout": "true", + "x-amzn-kiro-agent-mode": "vibe", + "amz-sdk-invocation-id": invocationId(), + }; + if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn; + return headers; +} + +/** Build one native CodeWhisperer GenerateAssistantResponse request from canonical OCX state. */ +export async function buildKiroNativeRequest( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, + forcedCompletionMode?: KiroCompletionMode, +): Promise { + if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { + throw new Error("kiro token missing — run ocx login kiro"); + } + + const region = resolveKiroApiRegion(parsed._kiroAuthContext); + const requestProfile = resolveKiroRequestProfile(parsed._kiroAuthContext); + const isApiKey = provider.apiKey.trim().startsWith("ksk_"); + const profileArn = isApiKey ? undefined : requestProfile.profileArn; + const wireClient: KiroWireClient = isApiKey || requestProfile.builderIdFallback || !profileArn ? "cli" : "ide"; + const headers = kiroNativeHeaders(provider, wireClient, isApiKey, profileArn); + + const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode, wireClient); + await normalizeKiroImages(built.payload); + const rawContextInputEstimate = estimateKiroPayloadInputTokens(built.payload, parsed.modelId); + const contextInputEstimate = calibrateKiroEstimate(built.conversationId, rawContextInputEstimate); + const body = JSON.stringify(built.payload); + + debugProviderDiagnostic("kiro", "request", { + region, + requestedModel: parsed.modelId, + completionMode: built.completionMode, + bodyBytes: new TextEncoder().encode(body).length, + messageCount: kiroPayloadMessages(parsed).length, + toolCount: parsed.context.tools?.length ?? 0, + hasProfileArn: Boolean(profileArn), + wireClient, + hasPreviousResponseId: Boolean(parsed.previousResponseId), + }); + + return { + request: { + url: kiroRuntimeEndpoint(provider, region), + method: "POST", + headers, + body, + usageLog: { inputTokens: estimateKiroLogInputTokens(parsed), estimated: true }, + }, + nameMap: built.nameMap, + conversationId: built.conversationId, + completionMode: built.completionMode, + inputTokens: estimateKiroInputTokens(parsed), + contextInputEstimate, + }; +} + +/** Stream-level throttles arrive after HTTP 200; record them at the native transport boundary. */ +export function noteKiroNativeTransientThrottle(): void { + noteKiroTransientThrottle(); +} + +/** Native fetch boundary, including the existing Kiro retry/cooldown rules. */ +export function fetchKiroNativeResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { + return fetchKiroWithRetry(request, ctx); +} diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 45c4d6bed5..ee63fc3fb8 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1,1921 +1,48 @@ -import { decodeEventStream } from "../lib/eventstream-decoder"; -import { estimateTokens } from "../lib/token-estimate"; -import { debugProviderDiagnostic } from "../lib/debug"; -import { resolveKiroApiRegion, resolveKiroRequestProfile } from "../oauth/kiro"; -import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; -import { modelRecordValue } from "../reasoning-effort"; -import { parseKiroEvent } from "./kiro-events"; -import { calibrateKiroEstimate, recordKiroCalibration, rekeyKiroCalibration } from "./kiro-calibration"; import { - classifyKiroEventError, - classifyKiroHttpError, - classifyKiroStreamError, - safeKiroErrorMessage, - safeKiroHttpErrorMessage, - type KiroErrorClassification, -} from "./kiro-errors"; -import { KiroThinkingParser } from "./kiro-thinking"; -import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation"; -import { createKiroToolNameRegistry, fallbackToolUseId, fingerprint, invocationId, isValidKiroConversationId, mapModelId, normalizeToolId, osTag, stableConversationId } from "./kiro-wire"; -import { namespacedToolName } from "../types"; -import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination"; -import { - isTranslatorBudgetExceededError, releaseTranslatedEvent, retainTranslatedEvent, type TranslatorBudget, } from "../lib/translator-budget"; -import type { - AdapterEvent, - OcxAssistantMessage, - OcxContentPart, - OcxMessage, - OcxParsedRequest, - OcxProviderConfig, - OcxTextContent, - OcxToolCall, - OcxToolResultMessage, - OcxUsage, -} from "../types"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import type { AdapterFetchContext, AdapterRequest } from "./base"; -import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images"; -import { sniffImageDimensions } from "./anthropic-image-guard"; -import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry"; -import { convertKiroToolContext } from "./kiro-tools"; -import { normalizeCodeModeToolResult } from "./exec-tool-result-normalize"; -import { neutralizeIdentity } from "./identity"; -import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge"; import { - KIRO_ANSWER_DELIVERED_MESSAGE, - KIRO_COMPLETION_INSTRUCTIONS, - KIRO_COMPLETION_RETRY_MESSAGE, - KIRO_COMPLETION_TOOL_NAME, - KIRO_CONTINUATION_MESSAGE, - KIRO_EMPTY_TOOL_RESULT_MESSAGE, - KIRO_TOOL_RESULT_CARRIER_MESSAGE, - MAX_KIRO_INJECTED_INSTRUCTION_CHARS, - type KiroCompletionMode, -} from "./kiro-constants"; - -const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; -const SDK_VERSION = "1.0.27"; -const NODE_VERSION = "22.21.1"; -const KIRO_IDE_VERSION = "1.0.0"; -const KIRO_FALLBACK_SERIALIZATION_ENVELOPE_BYTES = 64 * 1024; -type KiroWireClient = "ide" | "cli"; - -function kiroCliPlatform(): "linux" | "macos" | "windows" { - return process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; -} - -function kiroCliUserAgent(includeAppVersion: boolean): string { - return [ - "aws-sdk-rust/1.3.15", - "ua/2.1", - "api/codewhispererstreaming/0.1.17975", - `os/${kiroCliPlatform()}`, - "lang/rust/1.92.0", - ...(includeAppVersion ? ["md/appVersion-2.14.2"] : []), - "m/F", - "app/AmazonQ-For-CLI", - ].join(" "); -} - -// Payload construction (conversationState) -interface KiroToolUse { - name: string; - input: Record; // OBJECT, not stringified - toolUseId: string; -} -interface KiroToolResult { - content: Array<{ text: string }>; - status: string; - toolUseId: string; -} -interface KiroUserInputMessage { - content: string; - modelId?: string; - origin?: string; - userInputMessageContext?: { - tools?: unknown[]; - toolResults?: KiroToolResult[]; - }; - images?: KiroImage[]; -} -interface KiroHistoryEntry { - userInputMessage?: KiroUserInputMessage; - assistantResponseMessage?: { - content: string; - toolUses?: KiroToolUse[]; - reasoningContent?: { redactedContent: string }; - }; -} - -function kiroToolWireNames(tools: readonly unknown[]): string[] { - return tools - .map(tool => { - const spec = (tool as { toolSpecification?: { name?: unknown } }).toolSpecification; - return typeof spec?.name === "string" ? spec.name : undefined; - }) - .filter((name): name is string => typeof name === "string"); -} - -function userContentText(content: string | OcxContentPart[]): string { - if (typeof content === "string") return content; - return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); -} - -function usageContentText(content: string | OcxContentPart[]): string { - if (typeof content === "string") return content; - return content - .map(p => { - if (p.type === "text") return p.text; - if (p.type === "image") return `[image:${p.detail ?? "auto"}]`; - return ""; - }) - .filter(Boolean) - .join("\n"); -} -function serializeForUsage(value: unknown): string { - try { return JSON.stringify(value); } catch { return String(value); } -} -function currentTurnUsageMessages(messages: OcxMessage[]): OcxMessage[] { - return messages.slice(messages.map(m => m.role).lastIndexOf("assistant") + 1).filter(m => m.role !== "assistant"); -} -function kiroPayloadMessages(parsed: OcxParsedRequest): OcxMessage[] { - return parsed.context.messages; -} - -function messageUsageText(msg: OcxMessage): string { - switch (msg.role) { - case "user": - case "developer": - return usageContentText(msg.content); - case "toolResult": - return [ - msg.toolName, - msg.toolCallId, - msg.isError ? "error" : "success", - usageContentText(msg.content), - ].filter(Boolean).join("\n"); - case "assistant": - return ""; - } -} - -function messageLogText(msg: OcxMessage): string { - if (msg.role !== "assistant") return messageUsageText(msg); - return msg.content.map(part => { - if (part.type === "text") return part.text; - if (part.type === "toolCall") return [part.name, part.id, serializeForUsage(part.arguments)].join("\n"); - return part.thinking; - }).filter(Boolean).join("\n"); -} - -function estimateKiroImageTokens(image: KiroImage): number { - const dimensions = sniffImageDimensions(image.source.bytes); - if (dimensions) { - return Math.max(256, Math.ceil(dimensions.width * dimensions.height / 750)); - } - const decodedBytes = Math.floor(image.source.bytes.length * 3 / 4); - return Math.max(256, Math.ceil(decodedBytes / 512)); -} - -function estimateKiroTokens(text: string, modelId?: string): number { - return estimateTokens(text, modelId ? `kiro/${modelId}` : "kiro"); -} - -// Per-entry JSON/role framing is invisible to the text walker but grows with conversation length. -const KIRO_ENTRY_FRAMING_TOKENS = 12; -// Newlines, quotes, tabs and backslashes expand when serialized onto the Kiro JSON wire. -const KIRO_JSON_ESCAPE_EXPANSION = 1.12; - -function estimateKiroPayloadInputTokens(payload: Record, modelId: string): number { - const conversationState = (payload as { - conversationState?: { - history?: KiroHistoryEntry[]; - currentMessage?: KiroHistoryEntry; - }; - }).conversationState; - if (!conversationState) return 0; - - const parts: string[] = []; - let imageTokens = 0; - const entries = [ - ...(conversationState.history ?? []), - ...(conversationState.currentMessage ? [conversationState.currentMessage] : []), - ]; - for (const entry of entries) { - const user = entry.userInputMessage; - if (user) { - if (user.content) parts.push(user.content); - for (const image of user.images ?? []) imageTokens += estimateKiroImageTokens(image); - const context = user.userInputMessageContext; - if (context?.tools?.length) parts.push(serializeForUsage(context.tools)); - if (context?.toolResults?.length) parts.push(serializeForUsage(context.toolResults)); - } - const assistant = entry.assistantResponseMessage; - if (assistant) { - if (assistant.content) parts.push(assistant.content); - if (assistant.toolUses?.length) parts.push(serializeForUsage(assistant.toolUses)); - } - } - return Math.ceil(estimateKiroTokens(parts.join("\n"), modelId) * KIRO_JSON_ESCAPE_EXPANSION) - + imageTokens - + entries.length * KIRO_ENTRY_FRAMING_TOKENS; -} - -function shouldCountStablePromptOverhead(parsed: OcxParsedRequest): boolean { - return !parsed.previousResponseId && !parsed.context.messages.some(m => m.role === "assistant"); -} - -function estimateKiroInputTokens(parsed: OcxParsedRequest): number { - const parts = currentTurnUsageMessages(parsed.context.messages) - .map(messageUsageText) - .filter(Boolean); - - if (shouldCountStablePromptOverhead(parsed)) { - if (parsed.context.systemPrompt?.length) parts.push(...parsed.context.systemPrompt); - if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); - } - - return estimateKiroTokens(parts.join("\n"), parsed.modelId); -} - -function estimateKiroLogInputTokens(parsed: OcxParsedRequest): number { - const parts = parsed.context.messages.map(messageLogText).filter(Boolean); - if (parsed.context.systemPrompt?.length) parts.push(...parsed.context.systemPrompt); - if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); - return Math.max(estimateKiroInputTokens(parsed), estimateKiroTokens(parts.join("\n"), parsed.modelId)); -} - -function kiroUpstreamContextWindow(modelId: string | undefined): number | undefined { - if (!modelId) return undefined; - const normalizedModelId = normalizeKiroModelId(modelId); - if (normalizedModelId === "auto") return undefined; - const window = modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, modelId) - ?? modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, normalizedModelId); - return typeof window === "number" && Number.isFinite(window) && window > 0 ? window : undefined; -} - -function kiroRuntimeEndpoint(provider: OcxProviderConfig, region: string): string { - const configured = new URL(provider.baseUrl); - if ( - /^runtime\.[a-z]{2}(?:-[a-z]+)+-\d\.kiro\.dev$/i.test(configured.hostname) - && configured.pathname === "/" - ) { - return `https://runtime.${region}.kiro.dev/`; - } - return configured.toString(); -} - -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 kiroNativeEffortField(modelId) ? "native" : "emulated"; -} - -function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined { - const effort = parsed.options.reasoning; - if (!effort || effort === "none") return undefined; - const maxTokens = parsed.options.maxOutputTokens || 4096; - const percent: Record = { - minimal: 0.10, - low: 0.20, - medium: 0.50, - high: 0.80, - xhigh: 0.90, - max: 0.95, - }; - const ratio = percent[effort]; - return ratio === undefined ? undefined : Math.max(1, Math.floor(maxTokens * ratio)); -} - -function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): string { - if (kiroReasoningMode(parsed.modelId) !== "emulated") return content; - const budget = kiroThinkingBudget(parsed); - if (!budget) return content; - const instruction = [ - "Think in English for better reasoning quality.", - "Be thorough and systematic, consider edge cases, challenge assumptions, and verify reasoning before answering.", - "After thinking, respond in the user's language.", - ].join("\n"); - return [ - "enabled", - `${budget}`, - `${instruction}`, - "", - content, - ].join("\n"); -} - -function validateKiroCapabilities(parsed: OcxParsedRequest): void { - const choice = parsed.options.toolChoice; - if (choice !== undefined && choice !== "auto" && choice !== "none") { - throw new Error("Kiro supports only automatic tool choice or tool_choice:none"); - } - if (parsed.options.serviceTier !== undefined) { - throw new Error("Kiro does not support service tiers"); - } - // Structured output is a real contract Kiro cannot honour: the wire has no - // schema-constrained response mode, so a caller expecting parseable JSON would receive - // prose and fail downstream. Refuse it. - // - // The rest of the Responses `text` object is not that. `text.verbosity` is a length - // preference and `text.format: {type:"text"}` is ordinary prose — the default output - // mode, which no capability flag governs and every correct client may send. Testing - // `_rawBody.text !== undefined` refused those turns for the mere PRESENCE of the key, - // the same mistake db040e70f removed one condition earlier where a permissive - // `parallel_tool_calls` hint was read as a requirement. - // - // Nothing needs stripping the way openai-responses strips a no-op verbosity: - // buildKiroPayload composes conversationState field by field from `parsed` and never - // spreads `_rawBody`, so a tolerated control is dropped by construction. The test - // asserts that absence so it stays true. - if (parsed._structuredOutput) { - throw new Error("Kiro does not support Responses structured output"); - } -} - -type KiroTurn = - | { - kind: "user"; - content: string; - images: KiroImage[]; - toolResults: KiroToolResult[]; - /** True only for the proxy-generated acknowledgement after a delivered final answer. */ - answerDeliveredAck?: boolean; - } - | { - kind: "assistant"; - content: string; - toolUses: KiroToolUse[]; - redactedReasoning?: string; - /** A Responses final_answer already shown to the user; this turn must not be resumed. */ - finalAnswer?: boolean; - }; - -/** True only when no later user/tool-result work follows the delivered final answer. */ -function hasTrailingDeliveredFinalAnswer(messages: readonly OcxMessage[], parsed?: OcxParsedRequest): boolean { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role !== "assistant") return false; - const assistant = message as OcxAssistantMessage; - if ((assistant.content ?? []).some(part => part.type === "toolCall")) return false; - const hasText = (assistant.content ?? []).some(part => part.type === "text" && part.text.trim()); - if (!hasText) continue; - return assistant.phase === "final_answer" - || (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages)); - } - return false; -} - -function appendTurnText(target: string, next: string): string { - if (!next) return target; - return target ? `${target}\n\n${next}` : next; -} - -function validateKiroConversationState(history: KiroHistoryEntry[], currentMessage: KiroHistoryEntry): void { - const entries = [...history, currentMessage]; - const pendingToolUses = new Set(); - let previousRole: "user" | "assistant" | undefined; - - for (const entry of entries) { - const user = entry.userInputMessage; - const assistant = entry.assistantResponseMessage; - if (Boolean(user) === Boolean(assistant)) { - throw new Error("Kiro conversation entries must contain exactly one message role"); - } - const role = user ? "user" : "assistant"; - if (role === previousRole) throw new Error("Kiro conversation roles must alternate"); - previousRole = role; - - if (user) { - const hasPayload = Boolean(user.content.trim()) - || Boolean(user.images?.length) - || Boolean(user.userInputMessageContext?.toolResults?.length); - if (!hasPayload) throw new Error("Kiro user messages must not be empty"); - for (const result of user.userInputMessageContext?.toolResults ?? []) { - if (!pendingToolUses.delete(result.toolUseId)) { - throw new Error(`Kiro tool result has no matching tool use ${JSON.stringify(result.toolUseId)}`); - } - if (!result.content.some(part => part.text.trim())) { - throw new Error(`Kiro tool result must not be empty ${JSON.stringify(result.toolUseId)}`); - } - } - continue; - } - - const toolUses = assistant?.toolUses ?? []; - if (!assistant?.content.trim() && toolUses.length === 0) { - throw new Error("Kiro assistant messages must not be empty"); - } - for (const toolUse of toolUses) { - if (pendingToolUses.has(toolUse.toolUseId)) { - throw new Error(`Kiro conversation contains duplicate tool use ${JSON.stringify(toolUse.toolUseId)}`); - } - pendingToolUses.add(toolUse.toolUseId); - } - } - if (pendingToolUses.size > 0) throw new Error("Kiro conversation contains an unanswered tool use"); -} - -function boundedInjectedInstruction(text: string, used: { value: number }): string | undefined { - const remaining = MAX_KIRO_INJECTED_INSTRUCTION_CHARS - used.value; - if (remaining <= 0 || !text) return undefined; - let result = text.length <= remaining ? text : text.slice(0, remaining); - // Never end the slice on a lone high surrogate: encoding it substitutes - // U+FFFD into the injected instruction. One step back keeps a valid pair - // out instead of a broken half. - if (result.length > 0) { - const last = result.charCodeAt(result.length - 1); - if (last >= 0xd800 && last <= 0xdbff) result = result.slice(0, -1); - } - used.value += result.length; - return result.length > 0 ? result : undefined; -} - -/** Test-only: exercise the surrogate-safe instruction bound directly. */ -export function boundedInjectedInstructionForTests(text: string, used: { value: number }): string | undefined { - return boundedInjectedInstruction(text, used); -} - -function kiroCompletionTool(): Record { - return { - toolSpecification: { - name: KIRO_COMPLETION_TOOL_NAME, - // The shared catalog nudge treats every listed tool as if a result must return. This one is - // different: a valid call is the terminal itself. State that at the schema surface where the - // model chooses tools, otherwise a finished model can keep working while waiting for a result - // that will never exist. - description: "Terminal completion channel, not an ordinary work tool. When the task is fully complete and no more work or tool calls are needed, you must call this tool exactly once instead of providing the final answer as ordinary assistant text. Call it the same way when you cannot continue until the user supplies a decision, information, or a clarification that only they can give: the question itself is the answer. Put the complete user-facing final answer in `answer`. The call is complete when issued: it ends the turn, returns no tool result, and no text or tool call may follow it.", - inputSchema: { - json: { - type: "object", - properties: { - answer: { - type: "string", - description: "The complete final answer to show the user, or the blocking question you need the user to answer before you can continue.", - }, - }, - required: ["answer"], - }, - }, - }, - }; -} - -export function buildKiroPayload( - parsed: OcxParsedRequest, - profileArn: string | undefined, - forcedCompletionMode?: KiroCompletionMode, - wireClient: KiroWireClient = "ide", -): { - payload: Record; - nameMap: Map; - conversationId: string; - completionMode: KiroCompletionMode; -} { - validateKiroCapabilities(parsed); - const modelId = mapModelId(parsed.modelId); - const registry = createKiroToolNameRegistry(); - const toolContext = convertKiroToolContext(parsed, registry); - const ordinaryTools = toolContext.tools; - // A replay that already ends in a delivered final answer has nothing left to complete. Keeping - // the private completion tool enabled here reopens the closed task even if the trailing prompt is - // neutral, because the model is still instructed to produce another terminal answer. - const trailingDeliveredAnswer = hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed); - const completionMode: KiroCompletionMode = forcedCompletionMode - ?? (ordinaryTools.length > 0 && !trailingDeliveredAnswer ? "required" : "disabled"); - const kiroTools = completionMode === "disabled" - ? ordinaryTools - : [...ordinaryTools, kiroCompletionTool()]; - const nameMap = toolContext.nameMap; - const systemParts: string[] = []; - const injectedChars = { value: 0 }; - // Neutralize Codex's GPT-5 identity line so a routed Kiro model never misreports as GPT-5/OpenAI - // and the proxy identity never leaks upstream. - if (parsed.context.systemPrompt?.length) systemParts.push(neutralizeIdentity(parsed.context.systemPrompt.join("\n\n"))); - for (const addition of toolContext.systemAdditions) { - const boundedAddition = boundedInjectedInstruction(addition, injectedChars); - if (boundedAddition) systemParts.push(boundedAddition); - } - const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames( - kiroToolWireNames(kiroTools), - undefined, - toolContext.codeModeExecName, - ); - const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined; - if (boundedNudge) systemParts.push(boundedNudge); - if (completionMode !== "disabled") { - const boundedCompletion = boundedInjectedInstruction(KIRO_COMPLETION_INSTRUCTIONS, injectedChars); - if (boundedCompletion) systemParts.push(boundedCompletion); - } - const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : ""; - const turns: KiroTurn[] = []; - const priorCalls = new Map(); - const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => { - const last = turns.at(-1); - if (last?.kind === "user") { - last.content = appendTurnText(last.content, content); - last.images.push(...images); - last.toolResults.push(...toolResults); - } else { - turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] }); - } - }; - const pushAssistant = ( - content: string, - toolUses: KiroToolUse[], - redactedReasoning?: string, - finalAnswer?: boolean, - ): 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; - // Finality follows the LAST merged component. Commentary after a final answer means work - // continued and therefore reopens the turn legitimately. - last.finalAnswer = finalAnswer === true; - } else { - turns.push({ - kind: "assistant", - content, - toolUses: [...toolUses], - ...(redactedReasoning ? { redactedReasoning } : {}), - ...(finalAnswer ? { finalAnswer: true } : {}), - }); - } - }; - - // Codex custom tools may emit several adjacent output items for one invocation (for example - // progress notifications followed by the final value). Kiro accepts one result per tool use, so - // coalesce only immediately adjacent outputs whose ORIGINAL ids are identical. The raw-id check - // is important: normalizeToolId is lossy (`|`, whitespace, truncation), and must never authorize a - // different result merely because two caller-controlled ids normalize to the same wire id. - let adjacentResult: { - rawId: string; - result: KiroToolResult; - texts: string[]; - count: number; - hasImages: boolean; - codeModeExec: boolean; - } | undefined; - const finishAdjacentResult = (): void => { - if (adjacentResult?.codeModeExec) { - const normalized = normalizeCodeModeToolResult(adjacentResult.texts, { - isError: adjacentResult.result.status === "error", - hasImages: adjacentResult.hasImages, - }); - if (normalized) { - adjacentResult.result.content = normalized.map(text => ({ text })); - adjacentResult = undefined; - return; - } - } - if (adjacentResult && adjacentResult.count > 1) { - if (adjacentResult.texts.some(text => text.trim())) { - adjacentResult.result.content = adjacentResult.texts.map(text => ({ text })); - } else if (adjacentResult.hasImages || adjacentResult.result.status === "error") { - adjacentResult.result.content = [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }]; - } - } - adjacentResult = undefined; - }; - - const payloadMessages = kiroPayloadMessages(parsed); - const replayMessagePrefixLength = Math.min( - Math.max(0, parsed._replayMessagePrefixLen ?? 0), - payloadMessages.length, - ); - for (let messageIndex = 0; messageIndex < payloadMessages.length; messageIndex++) { - const msg = payloadMessages[messageIndex]; - const isReplayedMessage = messageIndex < replayMessagePrefixLength; - // Preserve source-message adjacency even when the turn normalization below would collapse or - // skip a structural message. - if (msg.role !== "toolResult") finishAdjacentResult(); - if (msg.role === "user" || msg.role === "developer") { - const text = userContentText((msg as { content: string | OcxContentPart[] }).content); - // Historical text/tool structure remains replayable, but image bytes are scoped to the turn - // that introduced them. Re-sending completed-turn images makes Kiro inspect the same visual on - // every unrelated follow-up and repeatedly pays the multimodal context cost. The parser-owned - // prefix boundary keeps current user/tool-result images intact, including the bounded internal - // completion retry built from this same parsed request. - const images = isReplayedMessage - ? [] - : extractKiroImages((msg as { content: string | OcxContentPart[] }).content); - pushUser(text, images); - } else if (msg.role === "assistant") { - const aMsg = msg as OcxAssistantMessage; - const rawText = (aMsg.content || []) - .filter((b): b is OcxTextContent => b.type === "text") - .map(b => b.text) - .join(""); - // Responses commentary is already-visible UI progress, but it can also carry durable task - // state: decisions, completed steps, rejected hypotheses, and the next action. Preserve it in - // Kiro's explicit history so tool-result continuations and compaction can resume from that - // state. Historical input is never re-emitted by the stream parser; the completion contract - // separately tells Kiro not to repeat or paraphrase an earlier progress update. - const text = rawText; - const toolCalls = (aMsg.content || []) - .filter((b): b is OcxToolCall => b.type === "toolCall"); - const toolUses: KiroToolUse[] = toolCalls.map(tc => { - const toolUseId = normalizeToolId(tc.id); - if (!toolUseId) throw new Error("Kiro history contains a tool call with an empty id"); - if (priorCalls.has(toolUseId)) throw new Error(`Kiro history contains duplicate tool call id ${JSON.stringify(tc.id)}`); - const wireName = namespacedToolName(tc.namespace, tc.name); - const name = registry.alias(wireName); - priorCalls.set(toolUseId, { wireName, rawId: tc.id }); - return { name, input: (tc.arguments ?? {}) as Record, toolUseId }; - }); - if (!text && toolUses.length === 0) { - const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim()); - if (hasReasoning) continue; - } - pushAssistant( - text, - toolUses, - aMsg.kiroRedactedReasoning, - aMsg.phase === "final_answer" && toolUses.length === 0, - ); - } else if (msg.role === "toolResult") { - const tr = msg as OcxToolResultMessage; - if (tr.containsEncryptedContent) { - throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`); - } - const text = userContentText(tr.content); - const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE; - const images = isReplayedMessage ? [] : extractKiroImages(tr.content); - // Retired image bytes still prove the tool produced output; do not diagnose them as a - // missing text() call merely because the fork omits old pixels from continuation history. - const hasImages = typeof tr.content !== "string" && tr.content.some(part => part.type === "image"); - const toolUseId = normalizeToolId(tr.toolCallId); - const call = priorCalls.get(toolUseId); - if (!call || call.rawId !== tr.toolCallId) { - throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`); - } - const last = turns.at(-1); - if ( - adjacentResult?.rawId === tr.toolCallId - && last?.kind === "user" - && last.toolResults.at(-1) === adjacentResult.result - ) { - adjacentResult.count += 1; - adjacentResult.hasImages ||= hasImages; - if (text.length > 0) adjacentResult.texts.push(text); - last.images.push(...images); - if (tr.isError) adjacentResult.result.status = "error"; - continue; - } - finishAdjacentResult(); - // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix. - // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code - // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the - // newest user intent behind boilerplate. Backfill below only when nothing else speaks. - const result: KiroToolResult = { - content: [{ text: resultText }], - status: tr.isError ? "error" : "success", - toolUseId, - }; - pushUser("", images, [result]); - adjacentResult = { - rawId: tr.toolCallId, - result, - texts: text.length > 0 ? [text] : [], - count: 1, - hasImages, - // Ownership comes from the paired call and the emitted freeform catalog, never tr.toolName. - codeModeExec: toolContext.codeModeExecName !== undefined && call.wireName === "exec", - }; - } - } - finishAdjacentResult(); - - if (turns.length === 0 || turns[0].kind === "assistant") { - turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] }); - } - const trailingTurn = turns.at(-1); - if (trailingTurn?.kind === "assistant") { - const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE; - turns.push({ - kind: "user", - content: trailingTurn.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText, - images: [], - toolResults: [], - ...(trailingTurn.finalAnswer ? { answerDeliveredAck: true } : {}), - }); - } - - // Give tool-result turns a carrier sentence ONLY when they carry no other text. This runs - // before the pop below so the current turn is covered too: skipping it there would ship an - // empty current content, which validateKiroConversationState accepts (tool results count as - // payload) and would therefore fail silently. - for (const turn of turns) { - if (turn.kind === "user" && !turn.content.trim() && turn.toolResults.length > 0) { - turn.content = KIRO_TOOL_RESULT_CARRIER_MESSAGE; - } - } - - const currentTurn = turns.pop(); - if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn"); - // Keep internal acknowledgement state separate from its text: a real user may quote the same - // sentence and must still receive ordinary thinking/completion behavior. - const answerDeliveredAck = currentTurn.answerDeliveredAck === true; - const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant" - ? { - assistantResponseMessage: { - content: turn.content, - ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}), - ...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}), - }, - } - : { - userInputMessage: { - content: turn.content, - modelId, - origin: wireClient === "cli" ? "KIRO_CLI" : "AI_EDITOR", - ...(turn.images.length > 0 ? { images: turn.images } : {}), - ...(turn.toolResults.length > 0 ? { userInputMessageContext: { toolResults: turn.toolResults } } : {}), - }, - }; - const history = turns.map(toEntry); - const currentEntry = toEntry(currentTurn); - const currentUim = currentEntry.userInputMessage!; - - if (systemPrefix) { - const firstUser = history.find(e => e.userInputMessage)?.userInputMessage; - if (firstUser) firstUser.content = systemPrefix + firstUser.content; - else currentUim.content = systemPrefix + currentUim.content; - } - if (kiroTools.length > 0) { - currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools }; - } - if (completionMode === "text_fallback") { - if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) { - currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE); - } - } else if ( - !currentUim.userInputMessageContext?.toolResults - && currentUim.content !== KIRO_CONTINUATION_MESSAGE - && !answerDeliveredAck - ) { - currentUim.content = injectKiroThinkingTags(currentUim.content, parsed); - } - - validateKiroConversationState(history, currentEntry); - const conversationId = stableConversationId(parsed); - const payload: Record = { - conversationState: { - chatTriggerType: "MANUAL", - ...(wireClient === "cli" ? { - agentContinuationId: crypto.randomUUID(), - agentTaskType: "vibe", - } : {}), - conversationId, - currentMessage: { userInputMessage: currentUim }, - ...(history.length > 0 ? { history } : {}), - }, - }; - const effort = parsed.options.reasoning; - 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 = { [effortField]: { effort } }; - } - if (profileArn) payload.profileArn = profileArn; - return { payload, nameMap, conversationId, completionMode }; -} - -// Stream parsing (shared by parseStream + parseResponse) -// CodeWhisperer GenerateAssistantResponse ALWAYS returns an AWS eventstream body (there is no -// non-streaming wire mode), so the streaming bridge and non-streaming Responses path decode the -// same way — parseResponse just collects what parseStream yields. -interface KiroAttemptParseResult { - terminal?: AdapterEvent; - needsFallback?: boolean; - usage?: OcxUsage; - providerState?: { kiro: { conversationId: string } }; - assistantText: string; - sawReasoning: boolean; -} - -interface KiroAttemptResult extends KiroAttemptParseResult { - releaseRetained(): void; -} - -interface KiroAttemptRetention { - trackReplacement(previousBytes: number, nextBytes: number): void; - retainEvent(event: AdapterEvent, bytes: number): void; - releaseEvent(event: AdapterEvent): void; - releaseAll(): void; -} - -function createKiroAttemptRetention(budget: TranslatorBudget): KiroAttemptRetention { - let retainedBytes = 0; - const eventBytes = new Map(); - return { - trackReplacement(previousBytes, nextBytes) { - retainedBytes = Math.max(0, retainedBytes - previousBytes) + nextBytes; - }, - retainEvent(event, bytes) { - retainedBytes += bytes; - eventBytes.set(event, bytes); - }, - releaseEvent(event) { - const bytes = eventBytes.get(event); - if (bytes === undefined) return; - eventBytes.delete(event); - retainedBytes = Math.max(0, retainedBytes - bytes); - budget.releaseRetained(bytes, { kind: "retained_collectors" }); - }, - releaseAll() { - if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" }); - retainedBytes = 0; - eventBytes.clear(); - }, - }; -} - -interface KiroFallbackAttempt { - response: Response; - inputTokens: number; - contextInputEstimate: number; - nameMap: Map; - conversationId: string; - releaseRequestBody?: () => void; -} - -function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { - let nextBytes = previousBytes + Buffer.byteLength(fragment); - const previousLast = previous.charCodeAt(previous.length - 1); - const fragmentFirst = fragment.charCodeAt(0); - if (previousLast >= 0xd800 && previousLast <= 0xdbff - && fragmentFirst >= 0xdc00 && fragmentFirst <= 0xdfff) { - nextBytes -= 2; - } - return nextBytes; -} - -/** Exact UTF-8 size JSON.stringify() will use for a string, without materializing that copy. */ -function jsonStringSerializedUtf8Bytes(value: string): number { - let bytes = 2; // Opening and closing quotes. - for (let index = 0; index < value.length; index++) { - const code = value.charCodeAt(index); - if (code === 0x22 || code === 0x5c) { - bytes += 2; - } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { - bytes += 2; - } else if (code < 0x20) { - bytes += 6; - } else if (code <= 0x7f) { - bytes += 1; - } else if (code <= 0x7ff) { - bytes += 2; - } else if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else { - bytes += 6; - } - } else if (code >= 0xdc00 && code <= 0xdfff) { - bytes += 6; - } else { - bytes += 3; - } - } - return bytes; -} - -interface KiroContextWindowState { - value?: number; -} - -type KiroFallbackFactory = ( - conversationId: string | undefined, - assistantText: string, - sawReasoning: boolean, - budget: TranslatorBudget, -) => Promise; - -function mergeKiroUsage( - first: OcxUsage | undefined, - second: OcxUsage | undefined, - preserveFirstContextGrowth = false, -): OcxUsage | undefined { - if (!first) return second; - if (!second) return first; - const sumOptional = (key: keyof OcxUsage): number | undefined => { - const a = first[key]; - const b = second[key]; - return typeof a === "number" || typeof b === "number" - ? (typeof a === "number" ? a : 0) + (typeof b === "number" ? b : 0) - : undefined; - }; - const totalTokens = typeof first.totalTokens === "number" && typeof second.totalTokens === "number" - ? first.totalTokens + second.totalTokens - : undefined; - const carriedContextTotal = preserveFirstContextGrowth && typeof first.contextTotalTokens === "number" - ? first.contextTotalTokens + second.outputTokens - : undefined; - const combinedOutputTokens = first.outputTokens + second.outputTokens; - return { - inputTokens: first.inputTokens + second.inputTokens, - outputTokens: combinedOutputTokens, - ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number" - ? { - contextTotalTokens: Math.max( - first.contextTotalTokens ?? 0, - second.contextTotalTokens ?? 0, - carriedContextTotal ?? 0, - combinedOutputTokens, - ), - } - : {}), - ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), - ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}), - ...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}), - ...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}), - ...(first.estimated || second.estimated ? { estimated: true } : {}), - }; -} - -function retryableKiroIncomplete( - reason: string, - message: string, - usage: OcxUsage, - providerState: { kiro: { conversationId: string } } | undefined, - retryable = true, -): AdapterEvent { - return { - type: "incomplete", - reason, - message, - usage, - retryable, - endTurn: false, - ...(providerState ? { providerState } : {}), - }; -} + hasTrailingDeliveredFinalAnswer, + jsonStringSerializedUtf8Bytes, + KIRO_FALLBACK_SERIALIZATION_ENVELOPE_BYTES, + prepareKiroCompletionRetry, +} from "./kiro-continuity"; +import { + kiroUpstreamContextWindow, + parseKiroStream, +} from "./kiro-codec"; +import type { KiroCompletionMode } from "./kiro-constants"; +import { safeKiroHttpErrorMessage } from "./kiro-errors"; +import { + buildKiroNativeRequest, + fetchKiroNativeResponse, + noteKiroNativeTransientThrottle, +} from "./kiro-transport"; + +export { + boundedInjectedInstructionForTests, + hasTrailingDeliveredFinalAnswer, +} from "./kiro-continuity"; +export { + buildKiroPayload, + isRetryableKiroStreamCatchError, + kiroReasoningMode, + parseKiroStream, +} from "./kiro-codec"; /** - * Catch-path retryability for #519: only transport/socket failures with no emitted output - * are replay-safe. Malformed event payloads (`invalid Kiro …`) and any post-output failure - * stay terminal — same spirit as cursor's emittedOutput gate. + * Stable ProviderAdapter facade for Kiro. + * + * Policy lives in kiro-continuity, wire translation in kiro-codec, and native + * CodeWhisperer auth/headers/fetch in kiro-transport. Keep this file limited to + * per-request orchestration between those boundaries. */ -export function isRetryableKiroStreamCatchError(err: unknown, emittedOutput: boolean): boolean { - if (emittedOutput) return false; - const message = err instanceof Error ? err.message : String(err); - if (/^invalid Kiro\b/i.test(message)) return false; - // Include Smithy/eventstream truncation (`eventstream: truncated message at end of stream`): - // partial frame + clean EOF with zero output is the same replay-safe class as a socket close. - return /socket connection was closed|connection(?: was)? closed unexpectedly|ECONNRESET|EPIPE|UND_ERR_|fetch failed|decoder failed|premature close|other side closed|unexpected EOF|network connection lost|terminated|truncated message at end of stream|eventstream:\s*truncated/i - .test(message); -} - -/** Native clean-stop reason eligible for bounded private-completion validation. */ -const KIRO_END_TURN_STOP_REASON = "END_TURN"; - -async function* parseKiroAttempt( - response: Response, - budget: TranslatorBudget, - mode: KiroCompletionMode, - modelId: string | undefined, - inputTokens: number, - contextWindowState: KiroContextWindowState, - nameMap: Map | undefined, - conversationId: string | undefined, - contextInputEstimate?: number, - /** True when an earlier attempt already flushed visible content to the client (#520). */ - priorEmittedOutput = false, -): AsyncGenerator { - // `required` mode holds staged commentary until a real tool call or terminal metadata identifies - // the attempt boundary. Anything the inner parser leaves behind is flushed before the terminal. - const deferred: AdapterEvent[] = []; - const retention = createKiroAttemptRetention(budget); - // The inner parser can observe Kiro's authoritative context checkpoint, but only this wrapper - // knows whether the attempt is terminal or will be followed by the bounded completion retry. - const attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } } = {}; - const attempt = parseKiroAttemptEvents( - response, - budget, - mode, - modelId, - inputTokens, - contextWindowState, - nameMap, - conversationId, - deferred, - retention, - attemptCalibration, - contextInputEstimate, - priorEmittedOutput, - ); - let handedOff = false; - try { - const result = yield* attempt; - const stagedCalibration = attemptCalibration.value; - attemptCalibration.value = undefined; - if (stagedCalibration && !result.needsFallback) { - recordKiroCalibration( - stagedCalibration.conversationId, - stagedCalibration.estimated, - stagedCalibration.charged, - ); - } - for (const event of deferred.splice(0)) { - try { yield event; } finally { retention.releaseEvent(event); } - } - handedOff = true; - return { ...result, releaseRetained: () => retention.releaseAll() }; - } finally { - if (!handedOff) retention.releaseAll(); - } -} - -async function* parseKiroAttemptEvents( - response: Response, - budget: TranslatorBudget, - mode: KiroCompletionMode, - modelId: string | undefined, - inputTokens: number, - contextWindowState: KiroContextWindowState, - nameMap: Map | undefined, - conversationId: string | undefined, - deferred: AdapterEvent[], - retention: KiroAttemptRetention, - attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } }, - contextInputEstimate?: number, - priorEmittedOutput = false, -): AsyncGenerator { - const emptyResult = (): KiroAttemptParseResult => ({ assistantText: "", sawReasoning: false }); - if (!response.body) { - return { - ...emptyResult(), - terminal: { type: "error", message: "Kiro response has no body", status: 502, errorType: "upstream_error" }, - }; - } - - let open: { id: string; name: string; chunks: string[]; completion: boolean } | null = null; - let openCallId: string | undefined; - const closeOpenCall = () => { - if (!openCallId) return; - budget.closeCall(openCallId); - openCallId = undefined; - }; - let outputChars = ""; - let outputCharsBytes = 0; - let contextUsagePercentage: number | undefined; - let returnedConversationId = conversationId; - let assistantText = ""; - let assistantTextBytes = 0; - let sawText = false; - let sawReasoning = false; - let sawRealTool = false; - let completionAnswer: string | undefined; - let completionCalls = 0; - let authoritativeUsage: OcxUsage | undefined; - let stopReason: string | undefined; - const fallbackEvents: AdapterEvent[] = []; - const thinking = new KiroThinkingParser(budget); - - const retainedEventBytes = (event: AdapterEvent): number => Buffer.byteLength(JSON.stringify(event)); - const retainEvent = (event: AdapterEvent): void => { - const bytes = retainedEventBytes(event); - budget.chargeRetained(bytes, { kind: "retained_collectors" }); - retention.retainEvent(event, bytes); - }; - const emitRetained = async function* (events: Iterable): AsyncGenerator { - for (const event of events) { - try { yield event; } finally { retention.releaseEvent(event); } - } - }; - // A valid private completion supersedes prose staged during the SAME inference. Kiro sometimes - // emits answer-shaped text and then calls the terminal tool; forwarding both makes Codex render - // two near-identical assistant messages. Drop only staged text on this proven completion path, - // preserve non-text events, and release every retained event either way. - const consumeSupersededByCompletion = async function* ( - events: AdapterEvent[], - ): AsyncGenerator { - for (const event of events.splice(0)) { - try { - if (event.type !== "text_delta") yield event; - } finally { - retention.releaseEvent(event); - } - } - }; - - const providerState = (): { kiro: { conversationId: string } } | undefined => - returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; - - const contextUsageTotalFloor = (): number | undefined => { - if (contextUsagePercentage === undefined || !contextWindowState.value) return undefined; - const floor = Math.ceil(contextWindowState.value * Math.min(contextUsagePercentage, 100) / 100); - return Number.isFinite(floor) && floor > 0 ? floor : undefined; - }; - const usage = (): OcxUsage => { - const base = authoritativeUsage ?? { - inputTokens, - outputTokens: estimateKiroTokens(outputChars, modelId), - estimated: true, - }; - const estimatedContextTotal = contextInputEstimate !== undefined - ? contextInputEstimate + base.outputTokens - : undefined; - const authoritativeTurnTotal = base.inputTokens + base.outputTokens; - const contextTotal = Math.max( - estimatedContextTotal ?? 0, - contextUsageTotalFloor() ?? 0, - authoritativeTurnTotal, - ); - return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base; - }; - - const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => { - // Upstream exception/error frames can arrive after commentary was already staged (and will be - // flushed before this terminal is yielded). Replaying after that content would duplicate it. - const emittedOutput = priorEmittedOutput - || sawText - || sawReasoning - || sawRealTool - || assistantText.length > 0 - || deferred.length > 0 - || completionAnswer !== undefined - || completionCalls > 0 - || open !== null - || fallbackEvents.length > 0; - if (failure.status === 429 && failure.retryable) noteKiroTransientThrottle(); - return { - type: "error", - message: failure.message, - status: failure.status, - errorType: failure.errorType, - code: failure.code, - retryable: emittedOutput ? false : failure.retryable, - usage: usage(), - }; - }; - - const protocolTerminal = (message: string, malformedCompletion = false): AdapterEvent => { - if (mode === "text_fallback" && malformedCompletion) { - return retryableKiroIncomplete( - "malformed_kiro_completion", - message, - usage(), - providerState(), - // First-attempt progress was already flushed before this bounded fallback (#520). - !priorEmittedOutput, - ); - } - return { - type: "error", - message, - status: 502, - errorType: "upstream_error", - code: malformedCompletion ? "invalid_kiro_completion" : "kiro_stream_protocol_error", - retryable: false, - usage: usage(), - }; - }; - - const classifyTool = ( - tool: { id: string; name: string; chunks: string[]; completion: boolean }, - ): AdapterEvent | undefined => { - if (tool.name !== KIRO_COMPLETION_TOOL_NAME) { - tool.completion = false; - return completionAnswer !== undefined || completionCalls > 0 - ? protocolTerminal("Kiro returned a real tool call alongside a private final answer") - : undefined; - } - if (mode === "disabled") { - return protocolTerminal("Kiro returned the reserved private final-answer tool while explicit completion was disabled"); - } - tool.completion = true; - if (completionAnswer !== undefined || completionCalls > 0) { - return protocolTerminal("Kiro returned more than one private final-answer tool call", true); - } - if (sawRealTool) { - return protocolTerminal("Kiro returned a private final answer alongside a real tool call"); - } - return undefined; - }; - - const beginTool = ( - id: string, - name: string, - ): { tool?: { id: string; name: string; chunks: string[]; completion: boolean }; terminal?: AdapterEvent } => { - const next = { id, name, chunks: [], completion: false }; - const terminal = classifyTool(next); - 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); - retainEvent(event); - return [{ type: "heartbeat" }]; - }; - - const stage = (event: AdapterEvent): AdapterEvent[] => { - if (event.type === "text_delta") { - const nextAssistantTextBytes = appendedUtf8Bytes(assistantText, assistantTextBytes, event.text); - const assistantReservation = budget.reserveTransient(nextAssistantTextBytes, { kind: "retained_collectors" }); - assistantText += event.text; - assistantReservation.commitRetained(); - budget.releaseRetained(assistantTextBytes, { kind: "retained_collectors" }); - retention.trackReplacement(assistantTextBytes, nextAssistantTextBytes); - assistantTextBytes = nextAssistantTextBytes; - if (event.text.trim()) sawText = true; - const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, event.text); - const outputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); - outputChars += event.text; - outputReservation.commitRetained(); - budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); - retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); - outputCharsBytes = nextOutputCharsBytes; - const phased = mode === "disabled" - ? event - : { ...event, phase: "commentary" as const }; - if (mode === "text_fallback") { - fallbackEvents.push(phased); - retainEvent(phased); - return []; - } - 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; - if (text.trim()) sawReasoning = true; - const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, text); - const reasoningReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); - outputChars += text; - reasoningReservation.commitRetained(); - budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); - retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); - outputCharsBytes = nextOutputCharsBytes; - } - if (mode === "text_fallback" && event.type !== "heartbeat") { - fallbackEvents.push(event); - retainEvent(event); - return []; - } - return mode === "required" ? defer(event) : [event]; - }; - - const parseCompletion = (chunks: string[]): string | Error => { - const raw = chunks.join("").trim(); - let value: unknown; - try { - value = JSON.parse(raw || "{}"); - } catch { - return new Error("Kiro returned invalid JSON for the private final-answer tool"); - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return new Error("Kiro returned a non-object value for the private final-answer tool"); - } - const answer = (value as { answer?: unknown }).answer; - if (typeof answer !== "string" || !answer.trim()) { - return new Error("Kiro returned an empty final answer"); - } - return answer; - }; - - const flushOpen = (): { events: AdapterEvent[]; terminal?: AdapterEvent } => { - if (!open) return { events: [] }; - const tool = open; - open = null; - closeOpenCall(); - const input = tool.chunks.join(""); - if (!isCompleteKiroToolInput(input)) { - return { events: [], terminal: protocolTerminal(kiroTruncationErrorMessage("incomplete tool input JSON"), tool.completion) }; - } - if (tool.completion) { - completionCalls++; - if (completionCalls > 1) { - return { events: [], terminal: protocolTerminal("Kiro returned more than one private final-answer tool call", true) }; - } - if (sawRealTool) { - return { events: [], terminal: protocolTerminal("Kiro returned a private final answer alongside a real tool call") }; - } - const answer = parseCompletion(tool.chunks); - if (answer instanceof Error) return { events: [], terminal: protocolTerminal(answer.message, true) }; - completionAnswer = answer; - return { events: [] }; - } - if (completionAnswer !== undefined || completionCalls > 0) { - return { events: [], terminal: protocolTerminal("Kiro returned a real tool call alongside a private final answer") }; - } - sawRealTool = true; - const restored = nameMap?.get(tool.name) ?? tool.name; - return { - events: [ - { type: "tool_call_start", id: tool.id, name: restored }, - ...tool.chunks.filter(Boolean).map(argumentsChunk => ({ type: "tool_call_delta", arguments: argumentsChunk }) as AdapterEvent), - { type: "tool_call_end" }, - ], - }; - }; - - try { - for await (const msg of decodeEventStream(response.body)) { - const mt = msg.headers[":message-type"]; - if (mt === "exception" || mt === "error") { - open = null; - return { - assistantText, - sawReasoning, - terminal: classifiedTerminal(classifyKiroStreamError(msg.headers, new TextDecoder().decode(msg.payload))), - }; - } - if (mt !== "event") { - open = null; - return { - assistantText, - sawReasoning, - terminal: protocolTerminal(`Kiro response protocol error: unsupported Smithy message type ${JSON.stringify(mt ?? "missing")}`), - }; - } - const eventType = msg.headers[":event-type"]; - if (!eventType) { - open = null; - return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: event is missing :event-type") }; - } - const ev = parseKiroEvent(eventType, msg.payload); - if (!ev) continue; - switch (ev.type) { - case "metadata": - if (ev.usage) authoritativeUsage = ev.usage; - 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)) { - rekeyKiroCalibration(returnedConversationId, ev.conversationId); - returnedConversationId = ev.conversationId; - } - break; - case "content": - if (ev.modelId) { - contextWindowState.value = kiroUpstreamContextWindow(ev.modelId) ?? contextWindowState.value; - } - if (open) { - open = null; - return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("content arrived before tool stop")) }; - } - if (ev.data) { - for (const contentEvent of thinking.feed(ev.data)) { - yield* emitRetained(stage(contentEvent)); - } - } - break; - case "reasoning": - for (const contentEvent of thinking.flush()) { - yield* emitRetained(stage(contentEvent)); - } - 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()) { - yield* emitRetained(stage(contentEvent)); - } - if (!open) { - if (ev.stop === true) { - return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: tool stop received without an open tool call") }; - } - if (!ev.toolUseId || !ev.name) { - return { assistantText, sawReasoning, terminal: protocolTerminal("Kiro response protocol error: new tool event is missing toolUseId or name") }; - } - const started = beginTool(ev.toolUseId, ev.name); - if (started.terminal) return { assistantText, sawReasoning, terminal: started.terminal }; - open = started.tool!; - budget.openCall(open.id); - openCallId = open.id; - } else if ( - (ev.toolUseId && ev.toolUseId !== open.id) - || (ev.name && open.name !== "unknown" && ev.name !== open.name) - ) { - closeOpenCall(); - open = null; - return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("tool input changed identity before stop")) }; - } - if (open && open.name === "unknown" && ev.name) { - open.name = ev.name; - const terminal = classifyTool(open); - if (terminal) { - open = null; - return { assistantText, sawReasoning, terminal }; - } - } - if (open && ev.input !== undefined) { - const previousCallBytes = open.chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk), 0); - const nextCallBytes = previousCallBytes + Buffer.byteLength(ev.input); - const callReservation = budget.reserveTransient(nextCallBytes, { kind: "tool_args", callId: open.id }); - open.chunks.push(ev.input); - callReservation.commitRetained(); - budget.releaseRetained(previousCallBytes, { kind: "tool_args", callId: open.id }); - const nextOutputCharsBytes = appendedUtf8Bytes(outputChars, outputCharsBytes, ev.input); - const toolOutputReservation = budget.reserveTransient(nextOutputCharsBytes, { kind: "retained_collectors" }); - outputChars += ev.input; - toolOutputReservation.commitRetained(); - budget.releaseRetained(outputCharsBytes, { kind: "retained_collectors" }); - retention.trackReplacement(outputCharsBytes, nextOutputCharsBytes); - outputCharsBytes = nextOutputCharsBytes; - } - if (ev.stop === true) { - const flushed = flushOpen(); - if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; - for (const event of flushed.events) { - yield* emitRetained(stage(event)); - } - } else { - yield { type: "heartbeat" }; - } - break; - } - case "invalid_state": - open = null; - return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(undefined, ev.message ?? "Kiro entered an invalid state")) }; - case "error": - open = null; - return { assistantText, sawReasoning, terminal: classifiedTerminal(classifyKiroEventError(ev.reason, ev.message)) }; - case "truncation": - open = null; - return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage(ev.data)) }; - } - } - - for (const contentEvent of thinking.flush()) { - yield* emitRetained(stage(contentEvent)); - } - if (open) { - const input = open.chunks.join(""); - if (!isCompleteKiroToolInput(input)) { - const privateTool = open.completion; - open = null; - return { - assistantText, - sawReasoning, - terminal: protocolTerminal(kiroTruncationErrorMessage("stream ended before tool stop"), privateTool), - }; - } - const flushed = flushOpen(); - if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; - for (const event of flushed.events) { - yield* emitRetained(stage(event)); - } - } - - const finalUsage = usage(); - const finalProviderState = providerState(); - if (contextUsagePercentage !== undefined) { - debugProviderDiagnostic("kiro", "context_usage", { - contextUsagePercentage, - ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}), - }); - } - // The percentage is an absolute post-response checkpoint. Remove generated output before - // comparing it with the request-only estimate, then stage the observation for the outer parser - // to commit only if this attempt is terminal (not the first half of a bounded fallback). - const chargedTotal = contextUsageTotalFloor(); - if (chargedTotal !== undefined && contextInputEstimate !== undefined) { - const chargedInput = chargedTotal - finalUsage.outputTokens; - if (chargedInput > 0 && returnedConversationId) { - attemptCalibration.value = { - conversationId: returnedConversationId, - estimated: contextInputEstimate, - charged: chargedInput, - }; - } - } - // Native stop metadata proves that this inference ended, but it does not prove that ordinary - // text is a final answer. Kiro has emitted END_TURN for progress prose, so tool-enabled turns - // still require the private completion call to distinguish commentary from completion (#531). - const normalizedStopReason = stopReason?.trim().toUpperCase(); - const nativeCompletionStop = (normalizedStopReason === KIRO_END_TURN_STOP_REASON - || normalizedStopReason === "STOP_SEQUENCE") - && sawText - && !sawRealTool - && completionAnswer === undefined - && completionCalls === 0; - - debugProviderDiagnostic("kiro", "attempt_complete", { - mode, - sawText, - sawReasoning, - sawRealTool, - completionCalls, - nativeCompletionStop, - ...(stopReason !== undefined ? { stopReason } : {}), - assistantChars: assistantText.length, - }); - - if (mode === "required") { - if (completionAnswer !== undefined) yield* consumeSupersededByCompletion(deferred); - else yield* emitRetained(deferred.splice(0)); - } - - if (mode === "text_fallback") { - if (completionAnswer !== undefined) { - yield* consumeSupersededByCompletion(fallbackEvents); - yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; - return { - assistantText, - sawReasoning, - terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, - }; - } - if (sawRealTool) { - yield* emitRetained(fallbackEvents); - return { - assistantText, - sawReasoning, - terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, - }; - } - if (sawText) { - for (const event of fallbackEvents) { - try { - if (event.type !== "text_delta") yield event; - else yield { ...event, phase: "final_answer" }; - } finally { - retention.releaseEvent(event); - } - } - return { - assistantText, - sawReasoning, - terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, - }; - } - yield* emitRetained(fallbackEvents); - return { - assistantText, - sawReasoning, - terminal: retryableKiroIncomplete( - sawReasoning ? "reasoning_only_kiro_fallback" : "empty_kiro_fallback", - sawReasoning - ? "Kiro produced reasoning but no final answer on its bounded completion retry" - : "Kiro produced no final answer on its bounded completion retry", - finalUsage, - finalProviderState, - // First-attempt progress was already flushed before this bounded fallback (#520). - !priorEmittedOutput, - ), - }; - } - - if (completionAnswer !== undefined) { - yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; - return { - assistantText, - sawReasoning, - terminal: { type: "done", usage: finalUsage, endTurn: true, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, - }; - } - if (sawRealTool) { - return { - assistantText, - sawReasoning, - terminal: { type: "done", usage: finalUsage, endTurn: false, ...(finalProviderState ? { providerState: finalProviderState } : {}) }, - }; - } - if (mode === "required" && nativeCompletionStop) { - return { - assistantText, - sawReasoning, - needsFallback: true, - usage: finalUsage, - providerState: finalProviderState, - }; - } - - // An explicit non-completion stop reason has already terminated this inference. Converting it into - // another model request would hide truncation behind a second paid call, and for context - // exhaustion it would resubmit a request that cannot fit. Only a MISSING stop reason falls - // through to the bounded compatibility fallback below. - // - // END_TURN and STOP_SEQUENCE with text take the bounded validation path above; reaching here - // with either means the turn produced no replayable text. - if (mode === "required" && normalizedStopReason !== undefined) { - const providerStateField = finalProviderState ? { providerState: finalProviderState } : {}; - const incomplete = (reason: string, retryable: boolean) => ({ - assistantText, - sawReasoning, - terminal: { - type: "incomplete" as const, - reason, - message: `Kiro stopped with ${normalizedStopReason} before an explicit final answer`, - usage: finalUsage, - retryable, - endTurn: false, - ...providerStateField, - }, - }); - - if (normalizedStopReason === "MODEL_CONTEXT_WINDOW_EXCEEDED") { - // Reuse the existing context-length contract (kiro-errors.ts) instead of inventing an - // incomplete reason: an unrecognized incomplete becomes a retryable 529 in Claude - // outbound, and `max_output_tokens` would make responses/state.ts cache this partial - // for continuation replay. Both invite a retry that cannot succeed. - return { - assistantText, - sawReasoning, - terminal: { - type: "error" as const, - message: "Kiro stopped because the model context window was exhausted", - status: 400, - errorType: "invalid_request_error", - code: "context_length_exceeded", - retryable: false, - usage: finalUsage, - }, - }; - } - if (normalizedStopReason === "MAX_TOKENS") return incomplete("max_output_tokens", true); - if (normalizedStopReason === "CONTENT_FILTERED" || normalizedStopReason === "GUARDRAIL_INTERVENED") { - return incomplete("content_filter", false); - } - if (normalizedStopReason === "MALFORMED_TOOL_USE") return incomplete("kiro_malformed_tool_use", false); - if (normalizedStopReason === "MALFORMED_MODEL_OUTPUT") return incomplete("kiro_malformed_model_output", false); - // TOOL_USE here means Kiro claimed a tool call it never emitted. - if (normalizedStopReason === "TOOL_USE") return incomplete("kiro_tool_use_without_call", false); - if (normalizedStopReason === KIRO_END_TURN_STOP_REASON || normalizedStopReason === "STOP_SEQUENCE") { - return incomplete(`kiro_${normalizedStopReason.toLowerCase()}_without_text`, false); - } - return incomplete(`kiro_${normalizedStopReason.toLowerCase() || "unknown_stop"}`, false); - } - // Kiro text has no trustworthy final/progress marker. When completion is required, ordinary - // text and reasoning remain unfinished until the one bounded fallback validates the turn. - if (mode === "required" && (sawText || sawReasoning)) { - return { assistantText, sawReasoning, needsFallback: true, usage: finalUsage, providerState: finalProviderState }; - } - if (!sawText && !sawReasoning) { - return { - assistantText, - sawReasoning, - terminal: retryableKiroIncomplete( - "empty_kiro_stream", - "Kiro returned a successful but empty response stream", - finalUsage, - finalProviderState, - ), - }; - } - return { - assistantText, - sawReasoning, - terminal: { - type: "done", - usage: finalUsage, - endTurn: mode === "disabled" ? sawText : false, - ...(finalProviderState ? { providerState: finalProviderState } : {}), - }, - }; - } catch (err) { - if (isTranslatorBudgetExceededError(err)) { - closeOpenCall(); - return { - assistantText, - sawReasoning, - terminal: { - type: "error", - status: 502, - errorType: "upstream_error", - code: "translation_buffer_limit", - message: "upstream translation buffer exceeded the safe limit", - }, - }; - } - // Mid-stream socket closes after response.created / heartbeats only must stay retryable: - // nothing was relayed to the client, so a string-body replay is safe (see #519 / cursor's - // emittedOutput gate). Once any assistant text, reasoning, tool, or deferred content exists - // — including content flushed by a prior attempt before a bounded fallback — fail closed; - // the client may already have partial output. Protocol parse throws stay non-retryable even - // with zero output. - const emittedOutput = priorEmittedOutput - || sawText - || sawReasoning - || sawRealTool - || assistantText.length > 0 - || deferred.length > 0 - || completionAnswer !== undefined - || completionCalls > 0 - || open !== null - || fallbackEvents.length > 0; - return { - assistantText, - sawReasoning, - terminal: { - type: "error", - message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)), - status: 502, - errorType: "server_error", - code: "kiro_stream_protocol_error", - retryable: isRetryableKiroStreamCatchError(err, emittedOutput), - usage: usage(), - }, - }; - } finally { - thinking.dispose(); - closeOpenCall(); - } -} - -export async function* parseKiroStream( - response: Response, - budget: TranslatorBudget, - modelId?: string, - inputTokens = 0, - contextWindow?: number, - nameMap?: Map, - conversationId?: string, - completionMode: KiroCompletionMode = "disabled", - fallbackFactory?: KiroFallbackFactory, - contextInputEstimate?: number, -): AsyncGenerator { - const contextWindowState: KiroContextWindowState = { value: contextWindow }; - const firstResult = yield* parseKiroAttempt( - response, - budget, - completionMode, - modelId, - inputTokens, - contextWindowState, - nameMap, - conversationId, - contextInputEstimate, - false, - ); - try { - if (!firstResult.needsFallback) { - if (firstResult.terminal) yield firstResult.terminal; - return; - } - if (!fallbackFactory) { - yield retryableKiroIncomplete( - "uncompleted_kiro_response", - "Kiro produced progress without an explicit final answer and no bounded retry transport was available", - firstResult.usage ?? { inputTokens, outputTokens: 0, estimated: true }, - firstResult.providerState, - ); - return; - } - - yield { type: "heartbeat" }; - // First attempt already flushed deferred progress before this point. Gate fallback - // setup/HTTP failures the same way as the second-stream catch so a replay cannot - // duplicate visible commentary (#520). - const priorEmittedOutput = Boolean(firstResult.assistantText.trim()) || firstResult.sawReasoning; - let firstAssistantText = firstResult.assistantText; - const firstHadAssistantText = firstAssistantText.length > 0; - let fallback: KiroFallbackAttempt; - try { - fallback = await fallbackFactory( - firstResult.providerState?.kiro.conversationId ?? conversationId, - firstAssistantText, - firstResult.sawReasoning, - budget, - ); - } catch (err) { - firstAssistantText = ""; - firstResult.assistantText = ""; - firstResult.releaseRetained(); - if (isTranslatorBudgetExceededError(err)) { - yield { - type: "error", - message: "upstream translation buffer exceeded the safe limit", - status: 502, - errorType: "upstream_error", - code: "translation_buffer_limit", - usage: firstResult.usage, - }; - return; - } - yield { - type: "error", - message: safeKiroErrorMessage({}, err instanceof Error ? err.message : String(err)), - status: err instanceof Error && err.name === "TimeoutError" ? 504 : 502, - errorType: "upstream_error", - retryable: !priorEmittedOutput, - usage: firstResult.usage, - }; - return; - } - // The factory has finished using the live first-attempt alias and has retained its own retry - // serialization through the fetch boundary. The discarded parser collectors can now release - // before the second attempt begins on the same turn budget. - firstAssistantText = ""; - firstResult.assistantText = ""; - firstResult.releaseRetained(); - fallback.releaseRequestBody?.(); - if (!fallback.response.ok) { - const payload = await fallback.response.text().catch(() => ""); - const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload); - yield { - type: "error", - message: failure.message, - status: failure.status, - errorType: failure.errorType, - code: failure.code, - retryable: priorEmittedOutput ? false : failure.retryable, - usage: firstResult.usage, - }; - return; - } - - const secondResult = yield* parseKiroAttempt( - fallback.response, - budget, - "text_fallback", - modelId, - fallback.inputTokens, - contextWindowState, - fallback.nameMap, - fallback.conversationId, - fallback.contextInputEstimate, - // First attempt already flushed deferred progress to the client before this fallback. - // A zero-output transport failure here must stay non-retryable to avoid duplicating that text. - priorEmittedOutput, - ); - try { - if (!secondResult.terminal) { - yield retryableKiroIncomplete( - "empty_kiro_fallback", - "Kiro's bounded completion retry ended without a terminal result", - mergeKiroUsage(firstResult.usage, secondResult.usage, firstHadAssistantText) - ?? { inputTokens, outputTokens: 0, estimated: true }, - secondResult.providerState ?? firstResult.providerState, - !priorEmittedOutput, - ); - return; - } - if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") { - yield { - ...secondResult.terminal, - // Belt-and-suspenders: never advertise a replay-safe incomplete after flushed progress. - ...(secondResult.terminal.type === "incomplete" && priorEmittedOutput - ? { retryable: false as const } - : {}), - usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText), - providerState: secondResult.terminal.providerState ?? firstResult.providerState, - }; - return; - } - yield { - ...secondResult.terminal, - ...(secondResult.terminal.type === "error" - ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, firstHadAssistantText) } - : {}), - }; - } finally { - secondResult.releaseRetained(); - } - } finally { - firstResult.releaseRetained(); - } -} - -// Adapter export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter { - // Per-request closure (resolveAdapter builds a fresh adapter per request — server.ts:440 — so this - // is race-free) carrying the heuristic input-token estimate from buildRequest into the stream. let inputTokens = 0; let contextInputEstimate = 0; let modelId: string | undefined; @@ -1927,88 +54,14 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter let firstRequestBodyBytes = 0; let requestAbortSignal: AbortSignal | undefined; - const build = async ( - parsed: OcxParsedRequest, - forcedCompletionMode?: KiroCompletionMode, - ): Promise<{ - request: AdapterRequest; - nameMap: Map; - conversationId: string; - completionMode: KiroCompletionMode; - inputTokens: number; - contextInputEstimate: number; - }> => { - if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { - throw new Error("kiro token missing — run ocx login kiro"); - } - const region = resolveKiroApiRegion(parsed._kiroAuthContext); - const requestProfile = resolveKiroRequestProfile(parsed._kiroAuthContext); - const isApiKey = provider.apiKey.trim().startsWith("ksk_"); - const profileArn = isApiKey ? undefined : requestProfile.profileArn; - // A Builder ID service profile does not turn the account into an enterprise identity. - // Use the same resolver verdict for the envelope, including legacy accountless calls. - const wireClient: KiroWireClient = isApiKey || requestProfile.builderIdFallback || !profileArn ? "cli" : "ide"; - const fp = fingerprint().slice(0, 64); - const headers: Record = wireClient === "cli" ? { - authorization: `Bearer ${provider.apiKey}`, - "content-type": "application/x-amz-json-1.0", - accept: "*/*", - "x-amz-target": AMZ_TARGET, - "user-agent": kiroCliUserAgent(true), - "x-amz-user-agent": kiroCliUserAgent(false), - "x-amzn-codewhisperer-optout": "true", - "amz-sdk-request": "attempt=1; max=3", - "amz-sdk-invocation-id": invocationId(), - ...(isApiKey ? { tokentype: "API_KEY" } : {}), - } : { - authorization: `Bearer ${provider.apiKey}`, - "content-type": "application/x-amz-json-1.0", - accept: "application/vnd.amazon.eventstream", - "x-amz-target": AMZ_TARGET, - "user-agent": `aws-sdk-js/${SDK_VERSION} ua/2.1 os/${osTag()} lang/js md/nodejs#${NODE_VERSION} api/codewhispererstreaming#${SDK_VERSION} m/E KiroIDE-${KIRO_IDE_VERSION}-${fp}`, - "x-amz-user-agent": `aws-sdk-js/${SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${fp}`, - "x-amzn-codewhisperer-optout": "true", - "x-amzn-kiro-agent-mode": "vibe", - "amz-sdk-invocation-id": invocationId(), - }; - if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn; - const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode, wireClient); - await normalizeKiroImages(built.payload); - const rawContextInputEstimate = estimateKiroPayloadInputTokens(built.payload, parsed.modelId); - const contextInputEstimate = calibrateKiroEstimate(built.conversationId, rawContextInputEstimate); - const body = JSON.stringify(built.payload); - debugProviderDiagnostic("kiro", "request", { - region, - requestedModel: parsed.modelId, - completionMode: built.completionMode, - bodyBytes: new TextEncoder().encode(body).length, - messageCount: kiroPayloadMessages(parsed).length, - toolCount: parsed.context.tools?.length ?? 0, - hasProfileArn: Boolean(profileArn), - wireClient, - hasPreviousResponseId: Boolean(parsed.previousResponseId), - }); - return { - request: { - url: kiroRuntimeEndpoint(provider, region), - method: "POST", - headers, - body, - usageLog: { inputTokens: estimateKiroLogInputTokens(parsed), estimated: true }, - }, - nameMap: built.nameMap, - conversationId: built.conversationId, - completionMode: built.completionMode, - inputTokens: estimateKiroInputTokens(parsed), - contextInputEstimate, - }; - }; + const build = (parsed: OcxParsedRequest, forcedCompletionMode?: KiroCompletionMode) => + buildKiroNativeRequest(provider, parsed, forcedCompletionMode); - const fallbackFactory: KiroFallbackFactory = async ( - returnedConversationId, - assistantText, - _sawReasoning, - budget, + const fallbackFactory = async ( + returnedConversationId: string | undefined, + assistantText: string, + _sawReasoning: boolean, + budget: TranslatorBudget, ) => { if (!requestSnapshot) throw new Error("Kiro completion retry lost its request state"); if (requestAbortSignal?.aborted) { @@ -2016,28 +69,8 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter ? requestAbortSignal.reason : new DOMException("Kiro request was cancelled", "AbortError"); } - const retryParsed = structuredClone(requestSnapshot); - retryParsed._providerContinuation = { - ...(retryParsed._providerContinuation ?? {}), - ...(returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : {}), - }; - // Reasoning is not replayable on the Kiro wire. Adding an empty assistant turn merely to mark - // that reasoning existed creates REQUEST_BODY_INVALID; only visible text earns a replay turn. - if (assistantText.trim()) { - retryParsed.context.messages.push({ - role: "assistant", - content: [{ type: "text" as const, text: assistantText }], - // This adapter-owned one-shot replay is durable evidence for the bounded completion retry. - // It is not emitted by history replay; only the provider's new response is streamed. - model: retryParsed.modelId, - timestamp: Date.now(), - }); - } - // The retry starts from the already measured first wire body, adds one JSON-escaped replay - // string, and only changes bounded Kiro-owned fields (completion prompt/tool, history wrapper, - // and <=256-byte conversation id). 64 KiB is a conservative envelope for those fixed fields. - // Reserve that complete upper bound while the first-attempt collectors are still charged so a - // near-cap turn fails before build() can materialize the retry payload or serialized body. + + const retryParsed = prepareKiroCompletionRetry(requestSnapshot, returnedConversationId, assistantText); const retryBodyUpperBound = firstRequestBodyBytes + jsonStringSerializedUtf8Bytes(assistantText) + KIRO_FALLBACK_SERIALIZATION_ENVELOPE_BYTES; @@ -2051,6 +84,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter if (retryBodyRetained) budget.releaseRetained(retryBodyBytes, { kind: "request_copies" }); else retryBodyReservation.release(); }; + try { const retry = await build(retryParsed, "text_fallback"); retryBodyBytes = Buffer.byteLength(retry.request.body); @@ -2060,7 +94,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter retryBodyReservation.commitRetained(); retryBodyRetained = true; budget.releaseRetained(retryBodyUpperBound - retryBodyBytes, { kind: "request_copies" }); - const response = await fetchKiroWithRetry(retry.request, { + const response = await fetchKiroNativeResponse(retry.request, { abortSignal: requestAbortSignal, returnRawErrors: true, stream: true, @@ -2081,11 +115,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter return { name: "kiro", - // Replayed history that already ends in the answer the user saw is not a new inference turn. - // This hook lets the server terminate locally before build/send and, critically, before the - // empty-completion guard can reinterpret an outputless terminal as something to retry. + localTerminal(parsed: OcxParsedRequest) { - return hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed) + return hasTrailingDeliveredFinalAnswer(parsed.context.messages, parsed) ? { reason: "kiro_final_answer_already_delivered" } : undefined; }, @@ -2117,28 +149,23 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter completionMode, completionMode === "required" ? fallbackFactory : undefined, contextInputEstimate, + noteKiroNativeTransientThrottle, ); }, fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { - // The normal Responses path supplies cancellation at fetch time rather than build time. - // Keep it for the adapter-owned bounded continuation so cancelling the client turn aborts - // both the first Kiro request and its one allowed completion retry. if (ctx?.abortSignal) requestAbortSignal = ctx.abortSignal; - return fetchKiroWithRetry(request, ctx); + return fetchKiroNativeResponse(request, ctx); }, formatErrorBody(status: number, headers: Headers, payloadText: string): string { return safeKiroHttpErrorMessage(status, headers, payloadText); }, - // Kiro always returns an event stream, including for non-streaming Responses requests. Drain - // the decoder into a budget-owned batch so an upstream stream cannot grow this array without - // bound while the caller waits for the complete JSON response. async parseResponse(response: Response, budget: TranslatorBudget): Promise { const events: AdapterEvent[] = []; try { - for await (const e of parseKiroStream( + for await (const event of parseKiroStream( response, budget, modelId, @@ -2149,9 +176,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter completionMode, completionMode === "required" ? fallbackFactory : undefined, contextInputEstimate, + noteKiroNativeTransientThrottle, )) { - retainTranslatedEvent(e, budget, events.at(-1)); - events.push(e); + retainTranslatedEvent(event, budget, events.at(-1)); + events.push(event); } return events; } catch (error) { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4bfd64b942..23be318a03 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -501,7 +501,7 @@ A Responses `parallel_tool_calls: true` value permits parallelism; it does not r unsupported wire control. Kiro accepts the hint without sending any parallel-control field. The existing Kiro preset and catalog still advertise serialized execution. Plain text output controls are likewise tolerated; actual schema-constrained output remains unsupported. -The current fork's commentary/image replay retirement and private completion contract are +The current fork's commentary preservation, completed-prefix image retirement, and private completion contract are independent of these input compatibility rules and must remain intact. Adjacent outputs from one custom-tool invocation are collapsed into a single Kiro result only when their original caller ids match exactly; normalized wire ids are never used as the ownership proof. Any non-result message is @@ -523,6 +523,41 @@ allowed for a blocking question when only the user can supply the missing decisi clarification, so Kiro does not write the question as commentary and then invent its own answer to keep the work loop moving. +### Kiro native boundary ownership + +Kiro is already a direct native transport: the proxy sends CodeWhisperer +`GenerateAssistantResponse` requests to Kiro rather than routing through another compatibility +server. The provider-specific code is split so a transport concern cannot silently redefine task +memory: + +- `kiro-continuity.ts` owns delivered-answer termination, completion-mode selection, the private + terminal tool/instruction budget, and preparation of the one bounded completion-validation replay. + It has no credential, endpoint, fetch, or event-stream dependency. +- `kiro-codec.ts` owns both directions of the Kiro wire translation: canonical OCX history to + `conversationState`, and AWS event-stream/Kiro events back to `AdapterEvent`. It preserves + commentary, tool/result ownership, redacted reasoning, replay-prefix image policy, usage/context + accounting, and completion-attempt parsing, but cannot resolve credentials or perform network I/O. +- `kiro-transport.ts` owns account-derived region/profile selection, CLI-vs-IDE envelope choice, + native headers/user agents, `x-amz-target`, runtime endpoint selection, request serialization after + image normalization, safe request diagnostics, and retry-aware fetch. It does not decide whether + assistant text is durable history or terminal output. +- `kiro.ts` is the stable `ProviderAdapter` facade. It keeps only the per-request state needed to + connect build/fetch/parse calls, constructs the bounded fallback through the three layers, and + re-exports the historical test/helper surface. + +The dependency rule is one-way: continuity contains policy without transport; codec may use +continuity and pure Kiro helpers; transport may use codec; the facade composes all three. Architecture +regressions are pinned by `tests/kiro-architecture-boundary.test.ts`. In particular, neither codec nor +transport may erase a canonical assistant message because it looks like UI-only commentary. + +[Decision Log] +- 목적과 의도: Make Kiro's direct native integration structurally comparable to other providers, while preventing UI/output policy from mutating durable task history. +- 기존 구현 및 제약 조건: A single 2,163-line `kiro.ts` mixed task continuity, CodeWhisperer payload mapping, event decoding, auth/region/header construction, retries, and the ProviderAdapter facade. That coupling allowed a fork-only commentary dedupe change to delete model memory. +- 검토한 주요 대안: Remove the Kiro adapter entirely; proxy the Codex Responses body directly to Kiro; split only helper files while keeping policy and transport mixed; or introduce explicit continuity/codec/transport boundaries behind the stable adapter surface. +- 선택한 방식: Keep the required protocol translation but split policy, bidirectional wire codec, native transport, and facade, retaining stable exports and behavior. +- 다른 대안 대신 이 방식을 선택한 이유: Kiro does not speak OpenAI Responses, so some translation is mandatory. Separating the translation from task policy preserves direct connectivity without repeating the memory-loss failure mode. +- 장점, 단점 및 영향: The adapter entry point is small and reviewable, auth/network changes cannot redefine history policy, and future upstream fixes have a clearer landing zone. The codec remains intentionally large because request and response wire translation share Kiro-specific state and accounting; further splitting is optional only when it preserves this dependency direction. + ### Kiro code-mode continuity The bounded catalog preserves tool-search discoveries ahead of ordinary declarations and reserves @@ -535,8 +570,8 @@ Kiro supplies the nested-helper discovery and explicit text/notify echo contract After adjacent outputs have been grouped by original call identity, empty code-mode results receive one missing-output explanation. Errors, nonempty output order, and current or retired image evidence are preserved. Known host failures gain an idempotent recovery hint only in leading error context. -No tool is executed or retried by this normalization. Commentary/image retirement, encrypted reasoning -pairing, private completion, and local delivered-answer termination remain independent. +No tool is executed or retried by this normalization. Commentary preservation, completed-prefix image retirement, +encrypted reasoning pairing, private completion, and local delivered-answer termination remain independent. [Decision Log] - 목적과 의도: Stop missing code-mode output and catalog eviction from looking like lost task state. diff --git a/tests/kiro-architecture-boundary.test.ts b/tests/kiro-architecture-boundary.test.ts new file mode 100644 index 0000000000..1585105d37 --- /dev/null +++ b/tests/kiro-architecture-boundary.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +describe("Kiro architecture boundaries", () => { + test("kiro.ts is a facade over explicit continuity, codec, and transport modules", () => { + const facade = read("src/adapters/kiro.ts"); + expect(facade).toContain('from "./kiro-continuity"'); + expect(facade).toContain('from "./kiro-codec"'); + expect(facade).toContain('from "./kiro-transport"'); + expect(facade).not.toContain("function validateKiroConversationState"); + expect(facade).not.toContain("decodeEventStream("); + expect(facade).not.toContain('"x-amz-target"'); + }); + + test("continuity policy cannot depend on Kiro network/auth transport", () => { + const continuity = read("src/adapters/kiro-continuity.ts"); + for (const forbidden of [ + "resolveKiroApiRegion", + "resolveKiroRequestProfile", + "fetchKiroWithRetry", + "decodeEventStream", + "x-amz-target", + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", + ]) { + expect(continuity).not.toContain(forbidden); + } + }); + + test("native codec maps state but cannot resolve credentials or fetch", () => { + const codec = read("src/adapters/kiro-codec.ts"); + expect(codec).toContain("buildKiroPayload"); + expect(codec).toContain("decodeEventStream"); + expect(codec).toContain("parseKiroStream"); + for (const forbidden of [ + "resolveKiroApiRegion", + "resolveKiroRequestProfile", + "fetchKiroWithRetry", + "noteKiroTransientThrottle", + 'from "./kiro-retry"', + "authorization:", + '"x-amz-target"', + ]) { + expect(codec).not.toContain(forbidden); + } + }); + + test("native transport owns auth/wire headers but not commentary/final-answer policy", () => { + const transport = read("src/adapters/kiro-transport.ts"); + expect(transport).toContain("AmazonCodeWhispererStreamingService.GenerateAssistantResponse"); + expect(transport).toContain('"x-amz-target"'); + expect(transport).toContain("resolveKiroApiRegion"); + expect(transport).not.toContain('phase === "commentary"'); + expect(transport).not.toContain("hasTrailingDeliveredFinalAnswer"); + expect(transport).not.toContain("KIRO_COMPLETION_INSTRUCTIONS"); + }); +});