diff --git a/devlog/_plan/260906_release_244_followups/030_kiro_results.md b/devlog/_plan/260906_release_244_followups/030_kiro_results.md index 20e9ca8273..52de89f8fb 100644 --- a/devlog/_plan/260906_release_244_followups/030_kiro_results.md +++ b/devlog/_plan/260906_release_244_followups/030_kiro_results.md @@ -1,6 +1,6 @@ # Adjacent Kiro result coalescing -Depends on task-input; class C3. Fix #3734 from recorded Codex code-mode output shape, never by spending live Kiro quota. +Depends on task-input; class C4 for protocol identity. Fix #3734 from recorded Codex code-mode output shape, never by spending live Kiro quota. ## Diff-level change map - MODIFY src/adapters/kiro.ts pushUser/turn-construction helper: when adding results in immediately adjacent parsed messages, tracked separately from collapsed user turns, combine only adjacent results with identical normalized toolUseId. Append content in exact input order and propagate error if any constituent is error. Preserve images via the adapter's supported representation; ensure no image is dropped or reordered relative to supported content semantics. @@ -19,3 +19,29 @@ No Kiro account/OAuth/quota changes, no aggressive malformed-history healing, no ## Source follow-up folded at roadmap lock Track adjacency in original message iteration; reset on every non-toolResult message including user/developer/assistant, even if pushUser collapses it into one user turn. Retain Kiro images on the current user image list as the existing wire format requires; do not promise unsupported text/image interleaving in the wire. Preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Local log metadata contains old Kiro activity and is not a current live reproduction. + +## Kiro-cycle P refresh on parent b24ed35a +Parent #3743 is verified and ready, still open as this branch base; fixture prerequisite #3745 is merged. Issue #3734 remains open without an author PR. kiroPayloadMessages currently returns parsed.context.messages unchanged, so tracking adjacency at the top of its loop observes original Ocx message barriers even when a reasoning-only assistant is later skipped or user/developer turns collapse. + +Concrete source edits in src/adapters/kiro.ts only: priorCalls values retain rawId alongside wireName; validate each result against that exact raw id after normalizing for wire lookup. This rejects different raw ids sharing a replacement/truncation result without banning legitimate paired non-wire ids. Track adjacentRawToolResultId, reset it for every non-toolResult before any early continue; for matching adjacent raw id and last user turn/last wire result, append text content and images, set status error if any constituent isError. Otherwise retain pushUser and final conversation validation. No global dedup, cross-turn merge or normalizer change. + +MODIFY tests/providers/kiro/kiro-adapter.test.ts only for regressions: parse a real Codex custom_call plus three adjacent custom outputs (optionally preceded by the parent external task input), assert one ordered result; error remains sticky and images survive including image-only later output; single-result control; A/B/A and user/developer/assistant/reasoning-only barriers reject. Raw-id controls cover pipe/underscore, whitespace, truncation and case mismatches; exact raw pairs still normalize and merge. Keep every orphan/encrypted and catalog test. No new test/layout files. + +MODIFY docs-site/src/content/docs/reference/adapters.md Kiro section and structure/04_transports-and-sidecars.md with this bounded contract. Preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Resolve roadmap review thread PRRT_kwDOS-0Gi86fozIF only after the raw identity fix is verified. + +Local evidence limit: saved Kiro conversation data and OCX diagnostic artifacts were inspected for field shapes only; no current Codex multi-output Kiro trace was available. No raw message, id or credential was emitted, and no live Kiro request was made. Synthetic CI fixtures are protocol regression evidence, not a field-success claim. + +Dispatch: main owns adapter/docs; bounded worker owns only kiro-adapter.test.ts. Independent A/C reviewers inspect raw identity, original-message adjacency, error/image propagation and unchanged encrypted rejection. Full runtime CI is remote only, including existing Kiro image/adapter tests; live Kiro is forbidden. + +## Resumed P after verified guidance parent b7e67d84d +The separate task-guidance cycle is complete, parent3743 P1 is resolved and CI34014313740 is green. Its verified head was merged into this preserved Kiro branch before implementation. Prior Euler review is folded below and must be rechecked before B. + +A contiguous group is finalized before any non-toolResult (including skipped reasoning-only assistant), before a different raw id, and after the loop. Track only local bookkeeping: rawId, reference to the fresh KiroToolResult, count, raw text parts and whether this group carried images; never put these fields on wire objects. A single-result group keeps the exact existing normalized text/fallback. For 2+ results, preserve ordered raw text parts except successful empty-exec wrappers, append images and keep any isError sticky. If the whole group has meaningful text, use those parts and remove any first-chunk empty fallback. Preserve whitespace text parts when meaningful text exists. If all text is empty, retain one existing fallback; use the neutral KIRO_EMPTY_TOOL_RESULT_MESSAGE when images or an error flag make an empty-success exec hint inappropriate. Failed exec wrappers are meaningful failure information and remain raw text in multi-result groups even when the incoming isError flag is false; preserve existing FAILED_EXEC_OUTPUT_MESSAGE for a single result. No new normalizer or message template. + +Read evidence: normalizeEmptyExecToolResultText distinguishes EMPTY_EXEC_OUTPUT_MESSAGE from FAILED_EXEC_OUTPUT_MESSAGE, and failed wrappers can arrive with isError=false. The wire validator requires at least one nonblank text part for each result; finalize groups before that unchanged validator. Keep the encrypted-content throw ahead of every grouping branch, and enforce exact raw id for every result, not only on coalescing. + +Additional regressions: later image-only/empty/success-empty wrapper does not inject placeholders into an already-populated result; initial empty then real text removes the empty hint; all-empty groups retain a valid nonblank result; multi-result failed wrapper retains its failure signal; later encrypted adjacent result still rejects; whitespace between meaningful chunks survives. Existing single empty/failed exec normalization tests must pass unchanged. + +## Resumed A dispositions +Accept whitespace concern: collect a nonzero-length raw text part when trim is empty OR the shared normalizer did not classify it as EMPTY_EXEC_OUTPUT_MESSAGE. This preserves whitespace between/before actual text while discarding only true empty-success wrapper text; failed wrappers are never in that drop category. Finalization decides whether the aggregate has meaningful text. +Rebut the need for duplicated tool-name bookkeeping: create the first fresh wire result using the EXISTING normalizeEmptyExecToolResultText(text,{toolName,toolNamespace}) call before registering the group. A one-result group is never rewritten at finalization, so its exact precomputed fallback is retained; no normalization without identity occurs. Multi-result finalization replaces that initial content only with raw aggregate parts (or neutral empty text for image/error groups). Tests pin the existing single-result behavior and no bookkeeping keys on wire. diff --git a/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md b/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md new file mode 100644 index 0000000000..39dde71c1c --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md @@ -0,0 +1,19 @@ +# Adjacent Kiro result implementation + +The adapter retains each original call ID beside its normalized wire ID and checks +that exact identity on every result. A local group tracks only adjacent results; +non-tool messages, another ID and end-of-input finalize it before the existing +conversation validator runs. The encrypted-result rejection still happens first. + +Single results keep their precomputed, tool-identity-aware normalization. Multiple +results keep ordered raw text, real whitespace and failed-exec wrapper information, +while empty-success wrappers do not become extra messages. An initial empty hint +is replaced when later text exists. Images remain on the user turn with existing +limits, error status is sticky, and entirely text-empty image/error groups use one +neutral fallback. Group bookkeeping remains outside Kiro wire objects. + +Regression coverage is added to the existing adapter test file, including the +parent task-input plus code-mode-output sequence, collision controls, barriers, +empty/failed wrappers and images. Yrlan's source contribution is attributed. +Proof is independent review and exact-head hosted CI; saved local metadata did not +contain a current multi-output reproduction and no live Kiro call is performed. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index c67fff2b99..c400709df8 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -261,6 +261,13 @@ header and does not guarantee a provider cache hit. - Builds Kiro `conversationState`, maps Codex tools and tool results, and sends image blocks supported by the Kiro wire. +- Coalesces adjacent outputs from the same original tool call into one Kiro result. Text remains + ordered, images retain the existing per-message limits, and any error flag remains set. User, + developer, assistant or another tool's output ends the group. Distinct original IDs that map + to the same normalized Kiro ID are rejected. +- Combined outputs keep real text and failure information without inserting an empty-output hint + for a later blank chunk. A single result keeps its existing normalization; an entirely text-empty + group receives one fallback, with neutral wording when images or an error flag are present. - Treats a client `parallel_tool_calls: true` value as permission rather than a wire requirement. Kiro remains serialized: the routed catalog advertises no parallel-tool capability and the adapter sends no parallel-control field upstream, but ordinary Codex tool turns are not rejected diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index fb83572d35..200b1edb77 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -44,7 +44,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 { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize"; +import { EMPTY_EXEC_OUTPUT_MESSAGE, normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize"; import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge"; import { @@ -663,7 +663,7 @@ export function buildKiroPayload( } const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : ""; const turns: KiroTurn[] = []; - const priorCalls = new Map(); + const priorCalls = new Map(); const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => { const last = turns.at(-1); if (last?.kind === "user") { @@ -695,7 +695,27 @@ export function buildKiroPayload( } }; + let adjacentResult: { + rawId: string; + result: KiroToolResult; + texts: string[]; + count: number; + hasImages: boolean; + } | undefined; + const finishAdjacentResult = (): void => { + 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; + }; + for (const msg of kiroPayloadMessages(parsed)) { + // Original-message adjacency matters even when a turn is collapsed or skipped below. + if (msg.role !== "toolResult") finishAdjacentResult(); if (msg.role === "user" || msg.role === "developer") { const text = userContentText((msg as { content: string | OcxContentPart[] }).content); const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content); @@ -714,7 +734,7 @@ export function buildKiroPayload( 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 }); + priorCalls.set(toolUseId, { wireName, rawId: tc.id }); return { name, input: (tc.arguments ?? {}) as Record, toolUseId }; }); if (!text && toolUses.length === 0) { @@ -735,26 +755,52 @@ export function buildKiroPayload( // the task instead of calling text()/notify(). Checked before `text.trim()` because the // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would // otherwise pass through as if it were real output. - const resultText = normalizeEmptyExecToolResultText(text, { + const normalizedExecText = normalizeEmptyExecToolResultText(text, { toolName: tr.toolName, toolNamespace: tr.toolNamespace, - }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE); + }); + const resultText = normalizedExecText ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE); const images = extractKiroImages(tr.content); const toolUseId = normalizeToolId(tr.toolCallId); - if (!priorCalls.has(toolUseId)) { + 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)}`); } + // Keep real whitespace and failed wrappers, but no empty-success wrapper boilerplate. + const rawGroupText = text.length > 0 && (!text.trim() || normalizedExecText !== EMPTY_EXEC_OUTPUT_MESSAGE) + ? text : undefined; + const last = turns.at(-1); + if ( + adjacentResult?.rawId === tr.toolCallId + && last?.kind === "user" + && last.toolResults.at(-1) === adjacentResult.result + ) { + adjacentResult.count += 1; + adjacentResult.hasImages ||= images.length > 0; + if (rawGroupText !== undefined) adjacentResult.texts.push(rawGroupText); + 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. - pushUser("", images, [{ + const result: KiroToolResult = { content: [{ text: resultText }], status: tr.isError ? "error" : "success", toolUseId, - }]); + }; + pushUser("", images, [result]); + adjacentResult = { + rawId: tr.toolCallId, result, + texts: rawGroupText === undefined ? [] : [rawGroupText], + count: 1, hasImages: images.length > 0, + }; } } + finishAdjacentResult(); if (turns.length === 0 || turns[0].kind === "assistant") { turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] }); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index dbc4a4618b..4ea5e30404 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -833,6 +833,14 @@ with seam heartbeats between bounded units. None of these clocks is a total gene ## Reasoning and tool-result compatibility +Kiro groups only consecutive original-message tool results whose raw call ID exactly matches +the originating call. Its wire-ID map retains the original ID privately so replacement or +truncation collisions cannot join unrelated results. Every non-tool message ends the group, +including a reasoning-only assistant omitted from the Kiro turns. Group finalization preserves +single-result normalization, ordered meaningful raw text and whitespace in multi-result output, +failure text, image order and sticky error status. Empty hints are applied once for an entirely +text-empty group, not once per chunk; local grouping state never enters the wire payload. + `src/responses/task-input.ts` recognizes complete external Codex task-input envelopes before translated Responses adapters: `function_call_output`, no `call_id` property, nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image output. diff --git a/tests/providers/kiro/kiro-adapter.test.ts b/tests/providers/kiro/kiro-adapter.test.ts index cb530a8d51..f4a9aa83e6 100644 --- a/tests/providers/kiro/kiro-adapter.test.ts +++ b/tests/providers/kiro/kiro-adapter.test.ts @@ -1079,6 +1079,275 @@ describe("kiro adapter — buildRequest", () => { ); }); + describe("adjacent Kiro result coalescing (#3734)", () => { + const execTool = { name: "exec", description: "Run JavaScript", parameters: { type: "object" } }; + const pngBytes = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const pngData = "data:image/png;base64," + pngBytes; + const emptyExecWrapper = "Script completed\nWall time 0.1 seconds\nOutput:\n"; + const failedExecWrapper = "Script failed\nWall time 0.1 seconds\nOutput:\n"; + const longRawId = "call_" + "x".repeat(64); + const truncatedRawId = "call_" + "x".repeat(59); + + function execCall(id: string) { + return { role: "assistant", content: [{ type: "toolCall", id, name: "exec", arguments: {} }] }; + } + function execResult(id: string, content: unknown, extra: Record = {}) { + return { role: "toolResult" as const, toolCallId: id, toolName: "exec", content, isError: false, ...extra }; + } + function currentUser(body: string) { + return JSON.parse(body).conversationState.currentMessage.userInputMessage as { + images?: Array<{ format: string; source: { bytes: string } }>; + userInputMessageContext: { toolResults: Array & { content: Array<{ text: string }>; status: string; toolUseId: string }> }; + }; + } + function expectWireResults(body: string, expected: Array<{ content: Array<{ text: string }>; status: string; toolUseId: string }>) { + const results = currentUser(body).userInputMessageContext.toolResults; + expect(results).toEqual(expected); + for (const result of results) { + expect(result).not.toHaveProperty("rawId"); + expect(result).not.toHaveProperty("count"); + expect(result).not.toHaveProperty("texts"); + expect(result).not.toHaveProperty("hasImages"); + expect(Object.keys(result).sort()).toEqual(["content", "status", "toolUseId"]); + } + return results; + } + + test("parseRequest keeps a complete external task input then coalesces custom exec notify/notify/final", async () => { + const raw = { + model: "claude-sonnet-4.5", + input: [ + { + type: "function_call_output", + id: "task_3735", + name: "Launch Task", + namespace: "agent.workspace", + output: "inspect grouping", + }, + { type: "custom_tool_call", call_id: "call_exec_group", name: "exec", input: "await tools.exec_command({cmd: 'ls'})" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "notify-one" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "notify-two" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "final-text" }, + ], + tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "text" } }], + }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + expect(raw).toEqual(original); + expect(parsed.context.messages).toMatchObject([ + { role: "user", content: "inspect grouping" }, + { role: "assistant", content: [{ type: "toolCall", id: "call_exec_group", name: "exec" }] }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "notify-one" }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "notify-two" }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "final-text" }, + ]); + + const messagesBefore = structuredClone(parsed.context.messages); + const { body } = await createKiroAdapter(provider).buildRequest(parsed); + expect(raw).toEqual(original); + expect(parsed.context.messages).toEqual(messagesBefore); + expectWireResults(body, [{ + content: [{ text: "notify-one" }, { text: "notify-two" }, { text: "final-text" }], + status: "success", + toolUseId: "call_exec_group", + }]); + }); + + test("direct Ocx three same-id results coalesce in order", async () => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "notify-one"), + execResult("call-x", "notify-two"), + execResult("call-x", "final-text"), + ]; + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool])); + expectWireResults(body, [{ + content: [{ text: "notify-one" }, { text: "notify-two" }, { text: "final-text" }], + status: "success", + toolUseId: "call-x", + }]); + }); + + test.each([ + { + name: "image and error stay sticky after a later success", + id: "call-sticky", + results: [ + execResult("call-sticky", [ + { type: "text", text: "caption" }, + { type: "image", imageUrl: pngData, detail: "high" }, + ], { isError: true }), + execResult("call-sticky", "later-ok"), + ], + content: [{ text: "caption" }, { text: "later-ok" }], + status: "error", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "image-only later output does not inject empty placeholders into text", + id: "call-img", + results: [ + execResult("call-img", "visible"), + execResult("call-img", [{ type: "image", imageUrl: pngData, detail: "high" }]), + ], + content: [{ text: "visible" }], + status: "success", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "initial empty placeholder is removed once later text exists", + id: "call-empty-then-text", + results: [execResult("call-empty-then-text", ""), execResult("call-empty-then-text", "later-text")], + content: [{ text: "later-text" }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "whitespace before and between meaningful chunks is preserved", + id: "call-ws", + results: [execResult("call-ws", " "), execResult("call-ws", "alpha"), execResult("call-ws", "\n\t "), execResult("call-ws", "beta")], + content: [{ text: " " }, { text: "alpha" }, { text: "\n\t " }, { text: "beta" }], + status: "success", + }, + { + name: "later successful empty exec wrapper is skipped", + id: "call-skip-empty", + results: [execResult("call-skip-empty", "keep-me"), execResult("call-skip-empty", emptyExecWrapper)], + content: [{ text: "keep-me" }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE, emptyExecWrapper], + }, + { + name: "all-empty success group keeps one empty-exec fallback", + id: "call-all-empty", + results: [execResult("call-all-empty", ""), execResult("call-all-empty", emptyExecWrapper)], + content: [{ text: EMPTY_EXEC_OUTPUT_MESSAGE }], + status: "success", + forbidden: [KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "all-empty image group uses neutral KIRO_EMPTY without an error", + id: "call-empty-img", + results: [ + execResult("call-empty-img", [{ type: "image", imageUrl: pngData, detail: "high" }]), + execResult("call-empty-img", ""), + ], + content: [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }], + status: "success", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE], + }, + { + name: "all-empty error group uses neutral KIRO_EMPTY without images", + id: "call-empty-error", + results: [execResult("call-empty-error", ""), execResult("call-empty-error", "", { isError: true })], + content: [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }], + status: "error", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE], + }, + { + name: "failed empty wrapper in a multi group stays raw", + id: "call-failed-multi", + results: [execResult("call-failed-multi", emptyExecWrapper), execResult("call-failed-multi", failedExecWrapper)], + content: [{ text: failedExecWrapper }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, FAILED_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "single empty exec group keeps option-aware fallback", + id: "call-single-empty", + results: [execResult("call-single-empty", emptyExecWrapper)], + content: [{ text: EMPTY_EXEC_OUTPUT_MESSAGE }], + status: "success", + forbidden: [KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "exact unusual raw pair still normalizes and coalesces", + id: "pipe|raw", + results: [execResult("pipe|raw", "left"), execResult("pipe|raw", "right")], + content: [{ text: "left" }, { text: "right" }], + status: "success", + toolUseId: "pipe_raw", + }, + ])("$name", async ({ id, results, content, status, images, forbidden, toolUseId }) => { + const messages = [{ role: "user", content: "run it" }, execCall(id), ...results]; + const original = structuredClone(messages); + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool])); + expect(messages).toEqual(original); + const current = currentUser(body); + expectWireResults(body, [{ content, status, toolUseId: toolUseId ?? id }]); + if (images) expect(current.images).toEqual(images); + const joined = current.userInputMessageContext.toolResults.flatMap(result => result.content.map(part => part.text)).join("\n"); + for (const token of forbidden ?? []) expect(joined).not.toContain(token); + }); + + test("A/B/A same-id repeat still fails the final validator", async () => { + const messages = [ + { role: "user", content: "run it" }, + { role: "assistant", content: [ + { type: "toolCall", id: "call-a", name: "exec", arguments: {} }, + { type: "toolCall", id: "call-b", name: "exec", arguments: {} }, + ] }, + execResult("call-a", "first-a"), + execResult("call-b", "first-b"), + execResult("call-a", "second-a"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "Kiro tool result has no matching tool use", + ); + }); + + test.each([ + { name: "user", barrier: { role: "user", content: "steer" } }, + { name: "developer", barrier: { role: "developer", content: "note" } }, + { name: "assistant", barrier: { role: "assistant", content: [{ type: "text", text: "mid" }] } }, + { name: "reasoning-only assistant", barrier: { role: "assistant", content: [{ type: "thinking", thinking: "plan" }] } }, + ])("$name barrier prevents coalescing the later same-id result", async ({ barrier }) => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "before"), + barrier, + execResult("call-x", "after"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "Kiro tool result has no matching tool use", + ); + }); + + test("later encrypted output throws before grouping even with the same raw id", async () => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "before"), + execResult("call-x", "opaque", { containsEncryptedContent: true }), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "cannot translate encrypted output", + ); + }); + + test.each([ + { name: "pipe vs underscore", callId: "call|raw", resultId: "call_raw" }, + { name: "whitespace vs underscore", callId: "call raw", resultId: "call_raw" }, + { name: "truncation", callId: longRawId, resultId: truncatedRawId }, + { name: "case", callId: "Call-Raw", resultId: "call-Raw" }, + { name: "empty result id", callId: "call-x", resultId: "" }, + ])("raw id mismatch ($name) remains orphaned", async ({ callId, resultId }) => { + const messages = [ + { role: "user", content: "run it" }, + execCall(callId), + execResult(resultId, "nope"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "orphaned tool result", + ); + }); + }); + test("adjacent user/developer and assistant items normalize without synthetic prose", async () => { const messages = [ { role: "developer", content: "first" },