From ad8382594c46b76855ea5ad527e5b49afd5900dc Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:20:11 +0900 Subject: [PATCH] fix(kiro): exclude runtime callbacks from retry snapshot --- src/adapters/kiro/adapter.ts | 5 ++++- structure/providers/kiro.md | 1 + .../providers/kiro/kiro-single-final.test.ts | 22 +++++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/adapters/kiro/adapter.ts b/src/adapters/kiro/adapter.ts index 11285f97573..b4d51979dd2 100644 --- a/src/adapters/kiro/adapter.ts +++ b/src/adapters/kiro/adapter.ts @@ -297,7 +297,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter toolNameMap = built.nameMap; conversationId = built.conversationId; completionMode = built.completionMode; - requestSnapshot = structuredClone(parsed); + // Runtime callbacks belong to the original request, not the retry payload. + requestSnapshot = structuredClone(Object.fromEntries( + Object.entries(parsed).filter(([, value]) => typeof value !== "function"), + )) as OcxParsedRequest; firstRequestBodyBytes = Buffer.byteLength(built.request.body); requestAbortSignal = incoming?.abortSignal; return built.request; diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index f324a6b1d0c..c17cb4ee260 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -159,6 +159,7 @@ budget until emitted, discarded, or cancelled; replay collectors are released af retry construction. Native `END_TURN` and `STOP_SEQUENCE` alone do not distinguish progress from an answer and therefore still require validation. Normal private completion and real tool calls need no completion retry. +The retry snapshots cloneable request fields without top-level runtime callbacks, leaving the original request intact. A turn can carry a real tool call and the private completion call together, in either order. The turn then continues through the real tools: the parser drops the completion call, ends the diff --git a/tests/providers/kiro/kiro-single-final.test.ts b/tests/providers/kiro/kiro-single-final.test.ts index a4bac94faed..12d09c28d45 100644 --- a/tests/providers/kiro/kiro-single-final.test.ts +++ b/tests/providers/kiro/kiro-single-final.test.ts @@ -38,17 +38,19 @@ function response(frames: Uint8Array[]): Response { } afterEach(resetKiroThrottleStateForTests); -async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false) { +async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false, input = structuredClone(parsed)) { const adapter = createKiroAdapter(provider); const budget = createTranslatorBudget(); const sends: number[] = []; const events: AdapterEvent[] = []; let visibleAtRetry: AdapterEvent[] = []; let physicalRequests = 0; + const bodies: string[] = []; try { - const request = await adapter.buildRequest(structuredClone(parsed)); + const request = await adapter.buildRequest(input); const upstream = await adapter.fetchResponse!(request, { - executor: (async () => { + executor: (async (_url, init) => { + bodies.push(String(init?.body)); if (++physicalRequests === 1) return response(first); visibleAtRetry = events.filter(event => event.type === "text_delta"); return response(retry); @@ -59,13 +61,25 @@ async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false) { else for await (const event of adapter.parseStream(upstream, budget)) events.push(event); if (buffered) for (const event of events) releaseTranslatedEvent(event, budget); expect(budget.snapshot().currentBytes).toBe(0); - return { events, sends, physicalRequests, visibleAtRetry }; + return { events, sends, physicalRequests, visibleAtRetry, bodies }; } finally { budget.dispose(); } } describe("Kiro single final answer (#6270)", () => { + test("advisor guard survives request building and completion retry", async () => { + const guard: NonNullable = async function* () {}; + const input = { ...structuredClone(parsed), _advisorGuard: guard }; + const answer = "The workspace is ready."; + const { events, physicalRequests, bodies } = await run([text(answer)], [text(answer)], false, input); + expect(input._advisorGuard).toBe(guard); + expect(physicalRequests).toBe(2); + expect(bodies[1]).toContain("Inspect the workspace."); + expect(bodies[1]).toContain("claude-opus-5.5"); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + for (const buffered of [false, true]) { test.each(["END_TURN", "STOP_SEQUENCE", undefined])( `plain text ending is held through validation (stop=%s, buffered=${buffered})`,