diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 556e0c7293..90ec713e6f 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -5,6 +5,207 @@ import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; +import { STREAM_FIRST_CHUNK_TIMEOUT_MS } from "../config/runtimeConfig.js"; + +const KIRO_REPAIR_BUFFER_MAX_BYTES = 8 * 1024 * 1024; +const KIRO_REPAIR_HEARTBEAT_MS = 10_000; +const KIRO_SHORT_FINAL_MAX_CHARS = 800; +const EVENTSTREAM_MAX_MESSAGE_BYTES = 24 * 1024 * 1024; +const EVENTSTREAM_MAX_HEADERS_BYTES = 128 * 1024; +const KIRO_EVENT_TYPES = new Set([ + "assistantResponseEvent", + "reasoningContentEvent", + "codeEvent", + "toolUseEvent", + "messageStopEvent", + "metadataEvent", + "MetadataEvent", + "contextUsageEvent", + "meteringEvent", + "metricsEvent" +]); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + } + return value >>> 0; +}); + +const REPAIR_INSTRUCTIONS = Object.freeze({ + tool: "Retry the previous response because its Kiro tool_call wrapper was malformed. If you use the wrapper tool named tool_call, its input must contain a non-empty name and an arguments field.", + ellipsis: "Retry the previous response because it ended with only an ellipsis. Return the complete final answer, not only ... or ….", + short_final: "Retry the previous response because its final only announced a future action. Complete the check now and return the result or a concrete blocker." +}); +const SHORT_FUTURE_ACTION = /^(?:(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再)(?:補|查|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|我(?:會|要|將)(?:再|重新)?(?:補(?:齊|查)?|抓取|查(?:詢)?|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b)/iu; +// Keep this tied to the observed whole-response signature. Broader Chinese +// result/progress heuristics create false positives for completed findings. +const OBSERVED_TRAILING_FUTURE_ACTION = /^目前證據顯示[\s\S]{1,700}[。.!?;;]\s*最後補查\s+504\s+access\s+log[,,]\s*確認\s+host[//]路徑與是否為集中流量[。.!]?$/iu; +const ENGLISH_FUTURE_ACTION = /^(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b/iu; +const ENGLISH_RESULT_CLAUSE = /(?:[:;\n]|[.!?]\s+\S|\b(?:status|checksum|response|deployment)\s+(?:is|are|was|were|matches?|equals?|returned)\b)/iu; +const CHINESE_FUTURE_ACTION = /^(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再|我(?:會|要|將)(?:再|重新)?)(?:補|抓取|查|確認|驗證|追|繼續|檢查|測試)/u; +const CHINESE_RESULT_CLAUSE = /(?:[。!?]\s*\S|(?:版本|狀態|回應|結果|部署|校驗碼)(?:是|為|等於|顯示))/u; +const USER_WAIT = /(?:請(?:你|先)|你(?:先|需要|可以|提供|確認|批准|允許)|等待(?:你|使用者)|等你|核准|同意|授權|\b(?:after|when|once)\s+you\b|\byour\s+(?:approval|confirmation|permission|input)\b|\bwait(?:ing)?\s+for\s+you\b|\bplease\s+(?:approve|confirm|provide|send)\b)/iu; +const COMPLETED_FINAL = /(?:已(?:經)?完成|完成(?:了|驗證|確認)|修復完成|確認無誤|驗證(?:完成|通過)|測試(?:均)?通過|結論|總結|\b(?:done|completed|fixed|verified|confirmed|passed|in conclusion|summary)\b|\b(?:is|are) complete\b)/iu; +const RESULT_EVIDENCE = /(?:顯示|發現|因此|成功|失敗|正常|無錯誤|沒有錯誤|\b(?:found|shows?|showed|because|therefore|succeeded|failed|healthy|green|no errors?)\b)/iu; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function envPositiveInt(name, fallback) { + const parsed = Number.parseInt(process.env?.[name] || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function concatChunks(chunks, totalBytes) { + const output = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function makeAbortError(reason) { + const error = new Error(reason?.message || reason || "Request aborted"); + error.name = "AbortError"; + return error; +} + +async function readWithTimeout(reader, signal, timeoutMs, message) { + if (signal?.aborted) throw makeAbortError(signal.reason); + let timeout; + let abortHandler; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + const abortPromise = new Promise((_, reject) => { + abortHandler = () => reject(makeAbortError(signal.reason)); + signal?.addEventListener("abort", abortHandler, { once: true }); + }); + try { + return await Promise.race([reader.read(), timeoutPromise, abortPromise]); + } finally { + clearTimeout(timeout); + signal?.removeEventListener?.("abort", abortHandler); + } +} + +async function readResponsePrefix(response, signal, maxBytes, timeoutMs) { + const reader = response?.body?.getReader?.(); + if (!reader) return ""; + const chunks = []; + let totalBytes = 0; + try { + while (totalBytes < maxBytes) { + const { done, value } = await readWithTimeout( + reader, + signal, + timeoutMs, + "Kiro retry error body stalled" + ); + if (done) break; + const remaining = maxBytes - totalBytes; + const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value; + chunks.push(chunk); + totalBytes += chunk.byteLength; + if (value.byteLength > remaining) break; + } + } finally { + await reader.cancel("bounded Kiro retry error body").catch(() => {}); + } + return decoder.decode(concatChunks(chunks, totalBytes)); +} + +function appendRepairInstruction(body, kind) { + const repaired = structuredClone(body || {}); + const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response."; + repaired.systemPrompt = repaired.systemPrompt + ? `${repaired.systemPrompt}\n\n${instruction}` + : instruction; + return repaired; +} + +function normalizeStopReason(value) { + const reason = String(value || "").trim().replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); + if (["endturn", "end_turn", "stop", "stop_sequence"].includes(reason)) return "end_turn"; + if (["tooluse", "tool_use", "tool_calls"].includes(reason)) return "tool_use"; + if (["maxtokens", "max_tokens", "max_output_tokens", "length"].includes(reason)) return "max_tokens"; + return reason || null; +} + +function stopDisposition(stopReason, hasToolCalls) { + if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure"; + if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete"; + if (stopReason === "refusal" || /(?:content.*filter|guardrail|safety|policy|blocked)/u.test(stopReason)) return "terminal_refusal"; + if (stopReason === "max_tokens") return hasToolCalls ? "terminal_incomplete" : "length"; + if (stopReason && !["end_turn", "tool_use"].includes(stopReason)) return "unknown_failure"; + if (hasToolCalls || stopReason === "tool_use") return "tool_use"; + if (!stopReason || stopReason === "end_turn") return "complete"; + return "unknown_failure"; +} + +function mergeStopReason(current, incoming) { + if (!incoming) return current; + if (!current) return incoming; + const severity = (reason) => { + const disposition = stopDisposition(reason, false); + if (disposition === "terminal_refusal") return 6; + if (disposition === "terminal_incomplete") return 5; + if (disposition === "unknown_failure") return 4; + if (disposition === "retryable_protocol_failure") return 3; + if (disposition === "length") return 2; + return 1; + }; + return severity(incoming) > severity(current) ? incoming : current; +} + +function isEllipsisOnly(value) { + return ["...", "…"].includes(String(value || "").trim()); +} + +function isShortFutureAction(value) { + const text = String(value || "").trim().replaceAll("’", "'"); + if (OBSERVED_TRAILING_FUTURE_ACTION.test(text)) return true; + if (ENGLISH_FUTURE_ACTION.test(text) && ENGLISH_RESULT_CLAUSE.test(text)) return false; + if (CHINESE_FUTURE_ACTION.test(text) && CHINESE_RESULT_CLAUSE.test(text)) return false; + return text.length > 0 && text.length <= KIRO_SHORT_FINAL_MAX_CHARS && + SHORT_FUTURE_ACTION.test(text) && !USER_WAIT.test(text) && + !COMPLETED_FINAL.test(text) && !RESULT_EVIDENCE.test(text); +} + +function encodeSSEError(code, message, details) { + return encoder.encode(`data: ${JSON.stringify({ error: { + message, + type: "upstream_error", + code, + ...(details ? { details } : {}) + } })}\n\ndata: [DONE]\n\n`); +} + +function inspectSSEChunk(chunk, state) { + for (const line of decoder.decode(chunk).split("\n")) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + if (event.error) state.error = event.error; + for (const choice of event.choices || []) { + const delta = choice.delta || {}; + if (typeof delta.content === "string") state.content += delta.content; + if (typeof delta.reasoning_content === "string") state.reasoning += delta.reasoning_content; + if (delta.tool_calls?.length) state.hasToolCalls = true; + } + } catch { /* a malformed SSE line is diagnosed by the transformer */ } + } +} /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -111,393 +312,776 @@ export class KiroExecutor extends BaseExecutor { */ async execute(args) { const result = await super.execute(args); - if (result?.response?.ok) { - result.response = this.transformEventStreamToSSE(result.response, args.model); - } + if (result?.response?.ok) this.attachIntegrityGate(result, args); return result; } - /** - * Transform AWS EventStream binary response to SSE text stream - * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout - */ - transformEventStreamToSSE(response, model) { - let buffer = new Uint8Array(0); - let chunkIndex = 0; + attachIntegrityGate(result, args) { + const abortController = new AbortController(); + const maxBytes = envPositiveInt("KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES", KIRO_REPAIR_BUFFER_MAX_BYTES); + const legacyTimeout = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TIMEOUT_MS", STREAM_FIRST_CHUNK_TIMEOUT_MS); + const ttftTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS", legacyTimeout); + const stallTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS", legacyTimeout); + const repairEnabled = args.credentials?.providerSpecificData?.kiroToolCallRepair !== false && + process.env.KIRO_TOOL_CALL_REPAIR !== "false"; + const forwardAbort = () => abortController.abort(args.signal?.reason); + args.signal?.addEventListener("abort", forwardAbort, { once: true }); + let open = true; + let heartbeatTimer; + + const stream = new ReadableStream({ + start: async (controller) => { + const heartbeat = () => { + if (!open) return; + try { + controller.enqueue(encoder.encode(": kiro-validation\n\n")); + } catch { + open = false; + } + }; + heartbeat(); + heartbeatTimer = setInterval(heartbeat, KIRO_REPAIR_HEARTBEAT_MS); + + try { + const bytes = await this.runIntegrityRecovery(result.response, args, { + signal: abortController.signal, + maxBytes, + ttftTimeoutMs, + stallTimeoutMs, + repairEnabled + }); + if (abortController.signal.aborted) throw makeAbortError(abortController.signal.reason); + controller.enqueue(bytes); + controller.close(); + } catch (error) { + if (open && error.name === "AbortError") { + controller.error(error); + } else if (open && error.name !== "AbortError") { + controller.enqueue(encodeSSEError( + "kiro_integrity_gate_failed", + error.message || "Kiro integrity validation failed" + )); + controller.close(); + } + } finally { + open = false; + clearInterval(heartbeatTimer); + args.signal?.removeEventListener?.("abort", forwardAbort); + } + }, + cancel(reason) { + open = false; + clearInterval(heartbeatTimer); + abortController.abort(reason || "client cancelled"); + } + }); + + result.response = new Response(stream, { + status: result.response.status, + statusText: result.response.statusText, + headers: { ...SSE_HEADERS } + }); + } + + async runIntegrityRecovery(rawResponse, args, options) { + const first = await this.readRecoverableIntegrityAttempt( + rawResponse, + args.model, + options, + "initial" + ); + if (first.kind === "complete") return first.bytes; + if (first.kind === "terminal_stop" || first.kind === "upstream_error") { + return this.integrityFailureSSE(first); + } + if (first.kind === "invalid_tool" && !options.repairEnabled) { + return encodeSSEError("invalid_kiro_tool_call", first.message, first.diagnostics); + } + + const repairKind = ["ellipsis", "short_final", "invalid_tool"].includes(first.kind) + ? first.kind + : null; + const repairBody = repairKind + ? appendRepairInstruction(args.body, repairKind === "invalid_tool" ? "tool" : repairKind) + : structuredClone(args.body || {}); + + const retry = await BaseExecutor.prototype.execute.call(this, { + ...args, + body: repairBody, + signal: options.signal + }); + if (!retry?.response?.ok) { + let body = ""; + try { + body = await readResponsePrefix( + retry?.response, + options.signal, + Math.min(options.maxBytes, 4096), + options.stallTimeoutMs + ); + } catch (error) { + if (error.name === "AbortError") throw error; + } + return encodeSSEError( + "kiro_integrity_retry_upstream_error", + body || `Kiro integrity retry failed with HTTP ${retry?.response?.status || 502}`, + { status: retry?.response?.status || 502 } + ); + } + + const second = await this.readRecoverableIntegrityAttempt( + retry.response, + args.model, + options, + "retry" + ); + if (second.kind === "complete") return second.bytes; + if (second.kind === "terminal_stop" || second.kind === "upstream_error") { + return this.integrityFailureSSE(second); + } + const code = second.kind === "ellipsis" + ? "kiro_ellipsis_retry_failed" + : second.kind === "short_final" + ? "kiro_short_final_retry_failed" + : second.kind === "invalid_tool" + ? "kiro_tool_call_repair_retry_failed" + : "kiro_missing_terminal_retry_failed"; + return encodeSSEError( + code, + `Kiro integrity validation failed after one bounded retry: ${second.message || second.kind}`, + { attempts: [first.diagnostics, second.diagnostics].filter(Boolean) } + ); + } + + integrityFailureSSE(attempt) { + const disposition = attempt.diagnostics?.stop_disposition; + const code = attempt.diagnostics?.terminal_provenance === "integrity_buffer_exceeded" + ? "kiro_integrity_buffer_exceeded" + : attempt.kind === "upstream_error" + ? "kiro_upstream_eventstream_error" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + return encodeSSEError(code, attempt.message || "Kiro stream ended with a terminal failure", attempt.diagnostics); + } + + async readRecoverableIntegrityAttempt(rawResponse, model, options, attempt) { + try { + return await this.readIntegrityAttempt(rawResponse, model, options, attempt); + } catch (error) { + if (error.name === "AbortError") throw error; + return { + kind: "missing_terminal", + message: error.message || "Kiro transport read failed", + diagnostics: { + attempt, + terminal_provenance: "transport_read_error", + transport_state: "upstream_error", + stop_reason: null, + stop_disposition: "terminal_incomplete", + response_state: "no_semantic_output", + event_counts: {}, + incomplete_frame_bytes: 0 + } + }; + } + } + + async readIntegrityAttempt(rawResponse, model, options, attempt) { + let diagnostics; + const transformed = this.transformEventStreamToSSE(rawResponse, model, { + maxToolBytes: Math.max(1, Math.floor(options.maxBytes / 2)), + onTerminalState: (value) => { + diagnostics = value; + } + }); + const reader = transformed.body.getReader(); + const chunks = []; + let totalBytes = 0; + let sawChunk = false; + const output = { content: "", reasoning: "", hasToolCalls: false, error: null }; + + try { + while (true) { + const timeoutMs = sawChunk ? options.stallTimeoutMs : options.ttftTimeoutMs; + const phase = sawChunk ? "stalled" : "timed out before first chunk"; + const { done, value } = await readWithTimeout( + reader, + options.signal, + timeoutMs, + `Kiro integrity validation ${phase}` + ); + if (done) break; + sawChunk = true; + totalBytes += value.byteLength; + if (totalBytes > options.maxBytes) { + await reader.cancel("kiro_integrity_buffer_exceeded").catch(() => {}); + return { + kind: "terminal_stop", + message: `Kiro integrity buffer exceeded ${options.maxBytes} bytes`, + diagnostics: { terminal_provenance: "integrity_buffer_exceeded" } + }; + } + chunks.push(value); + inspectSSEChunk(value, output); + } + } catch (error) { + await reader.cancel(error.message).catch(() => {}); + throw error; + } + + const safeDiagnostics = { + attempt, + terminal_provenance: diagnostics?.terminal_provenance || "missing_terminal_diagnostics", + transport_state: diagnostics?.transport_state || "unknown", + stop_reason: diagnostics?.stop_reason || null, + stop_disposition: diagnostics?.stop_disposition || "terminal_incomplete", + response_state: diagnostics?.response_state || "no_semantic_output", + event_counts: diagnostics?.event_counts || {}, + incomplete_frame_bytes: diagnostics?.incomplete_frame_bytes || 0 + }; + if (safeDiagnostics.stop_disposition === "retryable_protocol_failure") { + const kind = safeDiagnostics.terminal_provenance === "invalid_tool_call" + ? "invalid_tool" + : "retryable_stop"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (safeDiagnostics.stop_disposition === "terminal_incomplete" || + safeDiagnostics.stop_disposition === "terminal_refusal" || + safeDiagnostics.stop_disposition === "unknown_failure") { + const kind = safeDiagnostics.terminal_provenance === "upstream_eventstream_error" + ? "upstream_error" + : safeDiagnostics.terminal_provenance === "integrity_buffer_exceeded" + ? "terminal_stop" + : ["metadata_stop_reason", "message_stop_event"].includes(safeDiagnostics.terminal_provenance) + ? "terminal_stop" + : "missing_terminal"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (output.error) { + return { kind: "missing_terminal", message: output.error.message, diagnostics: safeDiagnostics }; + } + if (!output.hasToolCalls) { + if (isEllipsisOnly(output.content) || + (!output.content.trim() && isEllipsisOnly(output.reasoning))) { + return { kind: "ellipsis", diagnostics: safeDiagnostics }; + } + if (isShortFutureAction(output.content)) { + return { kind: "short_final", diagnostics: safeDiagnostics }; + } + } + return { kind: "complete", bytes: concatChunks(chunks, totalBytes), diagnostics: safeDiagnostics }; + } + + transformEventStreamToSSE(response, model, options = {}) { const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); const capabilityModel = resolveKiroModel(model).upstream; const contextWindow = getCapabilitiesForModel("kiro", capabilityModel).contextWindow || 200000; + const eventCounts = {}; const state = { - endDetected: false, - finishEmitted: false, + buffer: new Uint8Array(0), + chunkIndex: 0, + toolCounter: 0, + tools: new Map(), + bufferedToolBytes: 0, + hasText: false, + hasReasoning: false, + hasCode: false, hasToolCalls: false, - hasReasoningContent: false, - reasoningChunkCount: 0, - toolCallIndex: 0, - seenToolIds: new Map(), - inThinking: false + sawToolUse: false, + explicitStop: false, + stopReason: null, + terminalProvenance: null, + transportState: "consuming_response", + totalContentLength: 0, + contextUsagePercentage: 0, + hasContextUsage: false, + hasMetering: false, + usage: null, + inThinking: false, + toolValidationError: null, + validatedFrames: 0, + finished: false }; - const transformStream = new TransformStream({ - async transform(chunk, controller) { - // Track output so we can emit a keepalive if this frame yields no chunk. - const enqueueCountBefore = chunkIndex; - // Append to buffer - const newBuffer = new Uint8Array(buffer.length + chunk.length); - newBuffer.set(buffer); - newBuffer.set(chunk, buffer.length); - buffer = newBuffer; - - // Parse events from buffer - let iterations = 0; - const maxIterations = 1000; - while (buffer.length >= 16 && iterations < maxIterations) { - iterations++; - const view = new DataView(buffer.buffer, buffer.byteOffset); - const totalLength = view.getUint32(0, false); - - if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break; - - const eventData = buffer.slice(0, totalLength); - buffer = buffer.slice(totalLength); - - const event = parseEventFrame(eventData); - if (!event) continue; - - const eventType = event.headers[":event-type"] || ""; - - // Track total content length for token estimation - if (!state.totalContentLength) state.totalContentLength = 0; - if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; - - // Handle assistantResponseEvent - if (eventType === "assistantResponseEvent" && event.payload?.content) { - let content = event.payload.content; - - // Kiro Claude models can leak blocks into the content stream. - // We strip these literal tags to prevent duplication, as the reasoning - // is already routed correctly via reasoningContentEvent. - if (state.inThinking) { - if (content.includes("")) { - state.inThinking = false; - const after = content.split("").slice(1).join(""); - content = after.startsWith("\n") ? after.substring(1) : after; - } else { - content = ""; // Drop entirely while inside thinking block - } - } else if (content.includes("")) { - state.inThinking = true; - if (content.includes("")) { - state.inThinking = false; - const before = content.split("")[0]; - const after = content.split("").slice(1).join(""); - content = before + (after.startsWith("\n") ? after.substring(1) : after); - } else { - content = content.split("")[0]; - } - } - - if (!content && state.hasReasoningContent) { - // If we stripped everything, skip emitting an empty content chunk - continue; - } - - state.totalContentLength += content.length; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: chunkIndex === 0 - ? { role: "assistant", content } - : { content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + const diagnostics = (overrides = {}) => ({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + stop_reason: state.stopReason, + stop_disposition: stopDisposition(state.stopReason, state.hasToolCalls), + response_state: state.hasToolCalls + ? "valid_tool" + : state.hasText || state.hasReasoning || state.hasCode + ? "text_reasoning" + : state.explicitStop + ? "explicit_stop" + : "no_semantic_output", + event_counts: { ...eventCounts }, + incomplete_frame_bytes: state.buffer.byteLength, + ...overrides + }); + const sseChunk = (delta, finishReason = null, usage) => encoder.encode(`data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}) + })}\n\n`); + const emitDelta = (controller, delta) => { + if (state.chunkIndex === 0) delta = { role: "assistant", ...delta }; + state.chunkIndex++; + controller.enqueue(sseChunk(delta)); + }; + const fail = (controller, provenance, code, message, extra = {}) => { + state.finished = true; + state.terminalProvenance = provenance; + state.transportState = extra.transport_state || "corrupt_frame"; + const detail = diagnostics({ + stop_disposition: extra.stop_disposition || "terminal_incomplete", + ...extra + }); + options.onTerminalState?.(detail); + controller.enqueue(encodeSSEError(code, message, detail)); + }; + const assertToolBufferBound = () => { + if (state.bufferedToolBytes <= (options.maxToolBytes || KIRO_REPAIR_BUFFER_MAX_BYTES / 2)) return; + const error = new Error("Kiro buffered tool input exceeded the integrity memory bound"); + error.code = "KIRO_BUFFER_EXCEEDED"; + throw error; + }; + const appendToolInput = (tool, input) => { + if (input === undefined) return; + if (typeof input === "string") { + if (tool.inputKind && tool.inputKind !== "string") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "string"; + tool.inputChunks ||= []; + tool.inputChunks.push(input); + state.bufferedToolBytes += encoder.encode(input).byteLength; + } else if (input && typeof input === "object" && !Array.isArray(input)) { + if (tool.inputKind && tool.inputKind !== "object") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "object"; + state.bufferedToolBytes -= tool.inputBytes || 0; + tool.inputObject = input; + tool.inputBytes = encoder.encode(JSON.stringify(input)).byteLength; + state.bufferedToolBytes += tool.inputBytes; + } else { + throw new Error("Kiro tool input must be a JSON object"); + } + assertToolBufferBound(); + }; + const parsedToolInput = (tool) => { + if (!tool.inputKind) throw new Error("Kiro tool call is missing input"); + if (tool.inputKind === "object") return tool.inputObject; + try { + const input = JSON.parse(tool.inputChunks.join("")); + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("not an object"); + return input; + } catch (error) { + throw new Error(`Kiro tool input must be valid object JSON (${error.message})`); + } + }; + const emitTools = (controller) => { + for (const tool of state.tools.values()) { + const input = parsedToolInput(tool); + if (tool.name === "tool_call") { + if (typeof input.name !== "string" || !input.name.trim()) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name"); } - - // Handle reasoningContentEvent (Kiro thinking / reasoning) - // Kiro returns reasoning as a separate event when the request system - // prompt contains enabled. Surface it - // as OpenAI delta.reasoning_content so downstream translators can map - // it back to Claude thinking blocks / Anthropic reasoning, etc. - if (eventType === "reasoningContentEvent") { - const reasoning = event.payload?.reasoningContentEvent || event.payload || {}; - const reasoningText = (typeof reasoning === "string") - ? reasoning - : (reasoning.text || reasoning.content || ""); - if (reasoningText) { - state.hasReasoningContent = true; - state.totalContentLength += reasoningText.length; - - const reasoningDelta = state.reasoningChunkCount === 0 && chunkIndex === 0 - ? { role: "assistant", reasoning_content: reasoningText } - : { reasoning_content: reasoningText }; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: reasoningDelta, - finish_reason: null - }] - }; - chunkIndex++; - state.reasoningChunkCount++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } + if (!Object.prototype.hasOwnProperty.call(input, "arguments")) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments"); } + } + const index = state.toolCounter++; + emitDelta(controller, { + tool_calls: [{ + index, + id: tool.id, + type: "function", + function: { name: tool.name, arguments: "" } + }] + }); + emitDelta(controller, { + tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }] + }); + state.hasToolCalls = true; + } + state.tools.clear(); + state.bufferedToolBytes = 0; + if (state.stopReason === "tool_use" && !state.hasToolCalls) { + throw new Error("Kiro tool_use stop reason did not include a complete tool call"); + } + }; + const processEvent = (event, controller) => { + const messageType = event.headers[":message-type"]; + if (messageType === "error" || messageType === "exception") { + fail( + controller, + "upstream_eventstream_error", + "kiro_upstream_eventstream_error", + event.payload?.message || `Kiro upstream sent an EventStream ${messageType}`, + { transport_state: "upstream_error" } + ); + return false; + } - // Handle codeEvent - if (eventType === "codeEvent" && event.payload?.content) { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { content: event.payload.content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + const eventType = event.headers[":event-type"] || ""; + const eventCountKey = KIRO_EVENT_TYPES.has(eventType) ? eventType : "other"; + eventCounts[eventCountKey] = (eventCounts[eventCountKey] || 0) + 1; + if (eventType === "assistantResponseEvent" && typeof event.payload?.content === "string") { + let content = event.payload.content; + if (state.inThinking) { + const end = content.indexOf(""); + if (end < 0) content = ""; + else { + state.inThinking = false; + content = content.slice(end + 11).replace(/^\n/u, ""); } - - // Handle toolUseEvent - if (eventType === "toolUseEvent" && event.payload) { - state.hasToolCalls = true; - const toolUse = event.payload; - const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; - - for (const singleToolUse of toolUses) { - const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; - const toolName = singleToolUse.name || ""; - const toolInput = singleToolUse.input; - - let toolIndex; - const isNewTool = !state.seenToolIds.has(toolCallId); - - if (isNewTool) { - toolIndex = state.toolCallIndex++; - state.seenToolIds.set(toolCallId, toolIndex); - - const startChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - ...(chunkIndex === 0 ? { role: "assistant" } : {}), - tool_calls: [{ - index: toolIndex, - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: "" - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)); - } else { - toolIndex = state.seenToolIds.get(toolCallId); - } - - if (toolInput !== undefined) { - let argumentsStr; - - if (typeof toolInput === 'string') { - argumentsStr = toolInput; - } else if (typeof toolInput === 'object') { - argumentsStr = JSON.stringify(toolInput); - } else { - continue; - } - - const argsChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: toolIndex, - function: { - arguments: argumentsStr - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`)); - } + } else { + const start = content.indexOf(""); + if (start >= 0) { + const end = content.indexOf("", start + 10); + if (end < 0) { + state.inThinking = true; + content = content.slice(0, start); + } else { + content = content.slice(0, start) + content.slice(end + 11).replace(/^\n/u, ""); } } - - // Handle messageStopEvent - if (eventType === "messageStopEvent") { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - state.finishEmitted = true; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle contextUsageEvent to extract contextUsagePercentage - if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) { - state.contextUsagePercentage = event.payload.contextUsagePercentage; - // Mark that we received context usage event - state.hasContextUsage = true; + } + if (content || !state.hasReasoning) { + state.hasText ||= content.length > 0; + state.totalContentLength += content.length; + emitDelta(controller, { content }); + } + } else if (eventType === "reasoningContentEvent") { + const value = event.payload?.reasoningContentEvent || event.payload || {}; + const content = typeof value === "string" ? value : value.text || value.content || ""; + if (content) { + state.hasReasoning = true; + state.totalContentLength += content.length; + emitDelta(controller, { reasoning_content: content }); + } + } else if (eventType === "codeEvent" && typeof event.payload?.content === "string") { + state.hasCode = true; + state.totalContentLength += event.payload.content.length; + emitDelta(controller, { content: event.payload.content }); + } else if (eventType === "toolUseEvent") { + state.sawToolUse = true; + if (state.toolValidationError) return true; + const values = Array.isArray(event.payload) ? event.payload : [event.payload]; + if (!values[0]) throw new Error("Kiro toolUseEvent is empty"); + for (const value of values) { + const name = typeof value?.name === "string" ? value.name.trim() : ""; + if (!name) throw new Error("Kiro toolUseEvent is missing a tool name"); + let id; + if (value.toolUseId == null) { + id = `call_${created}_${state.tools.size + 1}`; + } else if (typeof value.toolUseId !== "string" || !value.toolUseId.trim()) { + throw new Error("Kiro toolUseEvent has an invalid toolUseId"); + } else { + id = value.toolUseId; } - - // Handle meteringEvent - mark that we received it - if (eventType === "meteringEvent") { - state.hasMeteringEvent = true; + let tool = state.tools.get(id); + if (!tool) { + tool = { id, name }; + state.tools.set(id, tool); + state.bufferedToolBytes += encoder.encode(id).byteLength + encoder.encode(name).byteLength + 32; + assertToolBufferBound(); + } else if (tool.name !== name) { + throw new Error("Kiro tool name changed between fragments"); } + appendToolInput(tool, value.input); + } + } else if (eventType === "messageStopEvent") { + state.explicitStop = true; + const reason = normalizeStopReason( + event.payload?.stopReason ?? event.payload?.stop_reason + ) || (state.sawToolUse ? "tool_use" : "end_turn"); + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "message_stop_event"; + state.stopReason = merged; + } else if (eventType === "metadataEvent" || eventType === "MetadataEvent") { + const metadata = event.payload?.metadataEvent || event.payload?.metadata || event.payload; + const reason = normalizeStopReason(metadata?.stopReason ?? metadata?.stop_reason); + if (reason) { + state.explicitStop = true; + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "metadata_stop_reason"; + state.stopReason = merged; + } + } else if (eventType === "contextUsageEvent") { + const percentage = Number(event.payload?.contextUsagePercentage); + if (Number.isFinite(percentage)) { + state.contextUsagePercentage = percentage; + state.hasContextUsage = true; + } + } else if (eventType === "meteringEvent") { + state.hasMetering = true; + const metering = event.payload?.meteringEvent || event.payload || {}; + const credits = Number(metering.usage); + if (Number.isFinite(credits)) { + state.usage = { + ...(state.usage || {}), + kiro_credits: credits, + kiro_credit_unit: typeof metering.unit === "string" ? metering.unit : "credit" + }; + } + } else if (eventType === "metricsEvent") { + const metrics = event.payload?.metricsEvent || event.payload || {}; + const prompt = Number(metrics.inputTokens) || 0; + const completion = Number(metrics.outputTokens) || 0; + if (prompt || completion) { + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; + const cacheRead = Number(metrics.cacheReadInputTokens || metrics.cache_read_input_tokens) || 0; + const cacheCreate = Number(metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens) || 0; + if (cacheRead) state.usage.cache_read_input_tokens = cacheRead; + if (cacheCreate) state.usage.cache_creation_input_tokens = cacheCreate; + } + } + return true; + }; + const processBytes = (chunk, controller) => { + const combinedLength = state.buffer.byteLength + chunk.byteLength; + if (combinedLength > (options.maxRawBytes || EVENTSTREAM_MAX_MESSAGE_BYTES)) { + fail( + controller, + "corrupt_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream buffered bytes exceed the protocol bound" + ); + return false; + } + if (state.buffer.byteLength === 0) { + state.buffer = chunk; + } else { + const joined = new Uint8Array(combinedLength); + joined.set(state.buffer); + joined.set(chunk, state.buffer.byteLength); + state.buffer = joined; + } - // Handle metricsEvent for token usage - if (eventType === "metricsEvent") { - // Extract usage data from metricsEvent payload - const metrics = event.payload?.metricsEvent || event.payload; - if (metrics && typeof metrics === 'object') { - const inputTokens = metrics.inputTokens || 0; - const outputTokens = metrics.outputTokens || 0; - // ponytail: Amazon Q upstream does not expose cache fields today, - // but pick up cache_read_input_tokens / cache_creation_input_tokens - // if the event shape grows them so cost tracking stays accurate. - const cachedTokens = metrics.cacheReadInputTokens || metrics.cache_read_input_tokens || 0; - const cacheCreationInputTokens = metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens || 0; - - if (inputTokens > 0 || outputTokens > 0) { - state.usage = { - prompt_tokens: inputTokens, - completion_tokens: outputTokens, - total_tokens: inputTokens + outputTokens - }; - // Kiro is Claude-backed: inputTokens EXCLUDES cache (Claude convention), - // not inclusive like OpenAI's cached_tokens. Emit cache_read_input_tokens - // (not cached_tokens) so canonicalizeUsage takes the Claude fold path and - // correctly adds cache back into prompt_tokens instead of undercharging. - if (cachedTokens > 0) state.usage.cache_read_input_tokens = cachedTokens; - if (cacheCreationInputTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationInputTokens; - } - } + while (state.buffer.byteLength >= 12) { + const view = new DataView(state.buffer.buffer, state.buffer.byteOffset); + if (view.getUint32(8, false) !== crc32(state.buffer.subarray(0, 8))) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream prelude CRC mismatch"); + return false; + } + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength < 16 || totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || headersLength > totalLength - 16) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream frame bounds are invalid"); + return false; + } + if (state.buffer.byteLength < totalLength) break; + const frame = state.buffer.slice(0, totalLength); + state.buffer = state.buffer.slice(totalLength); + let event; + try { + event = parseEventFrame(frame); + } catch (error) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", error.message); + return false; + } + state.transportState = "valid_complete_frame"; + state.validatedFrames++; + try { + if (!processEvent(event, controller)) return false; + } catch (error) { + const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED"; + if (!bufferExceeded) { + state.toolValidationError ||= error.message; + state.tools.clear(); + state.bufferedToolBytes = 0; + continue; } - - // Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent - if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) { - state.finishEmitted = true; - - // Estimate tokens if not available from events - if (!state.usage) { - // Estimate output tokens from content length - const estimatedOutputTokens = state.totalContentLength > 0 - ? Math.max(1, Math.floor(state.totalContentLength / 4)) - : 0; - - // Estimate input tokens from contextUsagePercentage - const estimatedInputTokens = state.contextUsagePercentage > 0 - ? Math.floor(state.contextUsagePercentage * contextWindow / 100) - : 0; - - state.usage = { - prompt_tokens: estimatedInputTokens, - completion_tokens: estimatedOutputTokens, - total_tokens: estimatedInputTokens + estimatedOutputTokens - }; - } - - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - - // Include usage in final chunk if available - if (state.usage) { - finishChunk.usage = state.usage; + fail( + controller, + "integrity_buffer_exceeded", + "kiro_integrity_buffer_exceeded", + error.message, + { + transport_state: state.transportState, + stop_disposition: "terminal_incomplete" } - - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); - } - } - - if (iterations >= maxIterations) { - console.warn("[Kiro] Max iterations reached in event parsing"); + ); + return false; } + } + return true; + }; + const finish = (controller) => { + if (state.finished) return; + if (state.buffer.byteLength) { + fail( + controller, + "incomplete_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream ended with a truncated frame", + { transport_state: "incomplete_frame" } + ); + return; + } + state.transportState = "clean_eof"; + const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse); + if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) { + const code = declaredDisposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : declaredDisposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : declaredDisposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: declaredDisposition } + ); + return; + } + if (state.toolValidationError) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + state.toolValidationError, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } + try { + emitTools(controller); + } catch (error) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + error.message, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } - // No client chunk produced this frame — emit an SSE comment keepalive - // so the stall watchdog sees upstream activity (ignored by parser/client). - if (chunkIndex === enqueueCountBefore && !state.finishEmitted) { - controller.enqueue(new TextEncoder().encode(": ka\n\n")); - } - }, + const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls; + if (!hasOutput && !state.explicitStop) { + fail( + controller, + "empty_response_eof", + "kiro_missing_terminal", + "Kiro EventStream ended without model output", + { transport_state: state.transportState } + ); + return; + } - flush(controller) { - // Emit finish chunk if not already sent - if (!state.finishEmitted) { - state.finishEmitted = true; - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); - } + const disposition = stopDisposition(state.stopReason, state.hasToolCalls); + if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) { + const code = disposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: disposition } + ); + return; + } - // Send final done message - controller.enqueue(new TextEncoder().encode(SSE_DONE)); + if (state.hasMetering && state.hasContextUsage && !state.usage?.total_tokens) { + const completion = state.totalContentLength + ? Math.max(1, Math.floor(state.totalContentLength / 4)) + : 0; + const prompt = Math.floor(state.contextUsagePercentage * contextWindow / 100); + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; } - }); + const finishReason = state.hasToolCalls + ? "tool_calls" + : disposition === "length" + ? "length" + : "stop"; + controller.enqueue(sseChunk({}, finishReason, state.usage)); + controller.enqueue(encoder.encode(SSE_DONE)); + state.finished = true; + options.onTerminalState?.(diagnostics({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + stop_disposition: disposition + })); + }; - // Pipe response body through transform stream if (!response.body) { - return new Response(SSE_DONE, { status: response.status, headers: { "Content-Type": "text/event-stream" } }); + const detail = diagnostics({ + terminal_provenance: "missing_response_body", + transport_state: "missing_body", + stop_disposition: "terminal_incomplete" + }); + options.onTerminalState?.(detail); + return new Response(encodeSSEError( + "kiro_missing_terminal", + "Kiro response did not include an EventStream body", + detail + ), { status: response.status, headers: { ...SSE_HEADERS } }); } - const transformedStream = response.body.pipeThrough(transformStream); - return new Response(transformedStream, { + const reader = response.body.getReader(); + const stream = new ReadableStream({ + start: async (controller) => { + try { + while (!state.finished) { + const { done, value } = await reader.read(); + if (done) break; + const chunksBefore = state.chunkIndex; + const framesBefore = state.validatedFrames; + if (!processBytes(value, controller)) { + await reader.cancel("invalid Kiro EventStream").catch(() => {}); + break; + } + if (state.validatedFrames > framesBefore && state.chunkIndex === chunksBefore) { + controller.enqueue(encoder.encode(": kiro-upstream\n\n")); + } + } + finish(controller); + controller.close(); + } catch (error) { + if (!state.finished) { + fail( + controller, + "upstream_read_error", + "kiro_missing_terminal", + error.message || "Kiro EventStream read failed", + { transport_state: "upstream_error" } + ); + } + controller.close(); + } + }, + cancel(reason) { + return reader.cancel(reason); + } + }); + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { ...SSE_HEADERS } @@ -527,65 +1111,90 @@ export class KiroExecutor extends BaseExecutor { /** * Parse AWS EventStream frame */ -function parseEventFrame(data) { - try { - const view = new DataView(data.buffer, data.byteOffset); - const headersLength = view.getUint32(4, false); - - // Parse headers - const headers = {}; - let offset = 12; // After prelude - const headerEnd = 12 + headersLength; - - while (offset < headerEnd && offset < data.length) { - const nameLen = data[offset]; - offset++; - if (offset + nameLen > data.length) break; - - const name = new TextDecoder().decode(data.slice(offset, offset + nameLen)); - offset += nameLen; - - const headerType = data[offset]; - offset++; - if (headerType === 7) { // String type - const valueLen = (data[offset] << 8) | data[offset + 1]; - offset += 2; - if (offset + valueLen > data.length) break; +function parseEventFrame(data) { + if (!(data instanceof Uint8Array) || data.byteLength < 16) { + throw new Error("AWS EventStream frame is shorter than 16 bytes"); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength !== data.byteLength) { + throw new Error("AWS EventStream frame length does not match its prelude"); + } + if (totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || + headersLength > totalLength - 16) { + throw new Error("AWS EventStream frame bounds are invalid"); + } + if (view.getUint32(8, false) !== crc32(data.subarray(0, 8))) { + throw new Error("AWS EventStream prelude CRC mismatch"); + } + if (view.getUint32(totalLength - 4, false) !== crc32(data.subarray(0, totalLength - 4))) { + throw new Error("AWS EventStream message CRC mismatch"); + } - const value = new TextDecoder().decode(data.slice(offset, offset + valueLen)); - offset += valueLen; - headers[name] = value; - } else { - break; - } + const headers = Object.create(null); + const names = new Set(); + let offset = 12; + const headerEnd = offset + headersLength; + const requireBytes = (count) => { + if (offset + count > headerEnd) { + throw new Error("AWS EventStream header exceeds its declared bounds"); } - - // Parse payload - const payloadStart = 12 + headersLength; - const payloadEnd = data.length - 4; // Exclude message CRC - - let payload = null; - if (payloadEnd > payloadStart) { - const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd)); - - // Skip empty or whitespace-only payloads - if (!payloadStr || !payloadStr.trim()) { - return { headers, payload: null }; - } - - try { - payload = JSON.parse(payloadStr); - } catch (parseError) { - // Log parse error for debugging - console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`); - payload = { raw: payloadStr }; - } + }; + + while (offset < headerEnd) { + requireBytes(1); + const nameLength = data[offset++]; + requireBytes(nameLength + 1); + const name = decoder.decode(data.subarray(offset, offset + nameLength)); + offset += nameLength; + if (names.has(name)) throw new Error(`AWS EventStream contains duplicate header: ${name}`); + names.add(name); + const type = data[offset++]; + + if (type === 0 || type === 1) { + headers[name] = type === 0; + } else if (type === 2) { + requireBytes(1); + headers[name] = view.getInt8(offset); + offset += 1; + } else if (type === 3) { + requireBytes(2); + headers[name] = view.getInt16(offset, false); + offset += 2; + } else if (type === 4) { + requireBytes(4); + headers[name] = view.getInt32(offset, false); + offset += 4; + } else if (type === 5 || type === 8) { + requireBytes(8); + offset += 8; + } else if (type === 6 || type === 7) { + requireBytes(2); + const valueLength = view.getUint16(offset, false); + offset += 2; + requireBytes(valueLength); + const bytes = data.subarray(offset, offset + valueLength); + headers[name] = type === 7 ? decoder.decode(bytes) : bytes; + offset += valueLength; + } else if (type === 9) { + requireBytes(16); + offset += 16; + } else { + throw new Error(`AWS EventStream header ${name} has unknown type ${type}`); } + } - return { headers, payload }; - } catch { - return null; + const payloadBytes = data.subarray(headerEnd, totalLength - 4); + if (payloadBytes.byteLength === 0) return { headers, payload: null }; + const payloadText = decoder.decode(payloadBytes); + if (!payloadText.trim()) return { headers, payload: null }; + try { + return { headers, payload: JSON.parse(payloadText) }; + } catch (error) { + throw new Error(`AWS EventStream payload is not valid JSON (${error.message})`); } } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index f25986c53b..9638824b6b 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -40,15 +40,21 @@ function pickAssistantMessageForChatCompletion(output) { */ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const chunks = []; + let streamError = null; for (const line of String(rawSSE || "").split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; - try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ } + try { + const chunk = JSON.parse(payload); + if (chunk?.error) streamError = chunk.error; + else chunks.push(chunk); + } catch { /* ignore malformed lines */ } } + if (streamError) return { error: streamError }; if (chunks.length === 0) return null; const first = chunks[0]; @@ -196,6 +202,12 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const sseText = await providerResponse.text(); const parsed = parseSSEToOpenAIResponse(sseText, model); if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + if (parsed.error) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + parsed.error.message || "Upstream SSE stream failed" + ); + } if (onRequestSuccess) await onRequestSuccess(); diff --git a/tests/unit/kiro-nonstream-error.test.js b/tests/unit/kiro-nonstream-error.test.js new file mode 100644 index 0000000000..d36b1c0a14 --- /dev/null +++ b/tests/unit/kiro-nonstream-error.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}) +})); + +const { FORMATS } = await import("../../open-sse/translator/formats.js"); +const { + handleForcedSSEToJson, + parseSSEToOpenAIResponse +} = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js"); + +describe("Kiro non-streaming error propagation", () => { + it("prefers a terminal SSE error over earlier semantic chunks", () => { + const raw = [ + 'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}', + 'data: {"error":{"message":"Kiro transport failed","code":"kiro_missing_terminal"}}', + "data: [DONE]" + ].join("\n\n"); + + expect(parseSSEToOpenAIResponse(raw, "kiro")).toEqual({ + error: { + message: "Kiro transport failed", + code: "kiro_missing_terminal" + } + }); + }); + + it("returns 502 instead of collapsing a failed Kiro SSE stream into stop", async () => { + const encoder = new TextEncoder(); + const raw = [ + 'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}', + 'data: {"error":{"message":"Kiro stream ended incompletely","code":"kiro_missing_terminal"}}', + "data: [DONE]", + "" + ].join("\n\n"); + const result = await handleForcedSSEToJson({ + providerResponse: new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(raw)); + controller.close(); + } + }), { headers: { "content-type": "text/event-stream" } }), + sourceFormat: FORMATS.OPENAI, + provider: "kiro", + model: "kr/claude-opus-4.8", + body: { model: "kr/claude-opus-4.8", messages: [] }, + stream: false, + requestStartTime: Date.now(), + connectionId: "test-connection", + clientRawRequest: { endpoint: "/v1/chat/completions" }, + trackDone: vi.fn(), + appendLog: vi.fn() + }); + const json = await result.response.json(); + + expect(result.success).toBe(false); + expect(result.response.status).toBe(502); + expect(json.error.message).toContain("Kiro stream ended incompletely"); + expect(json).not.toHaveProperty("choices"); + }); +}); diff --git a/tests/unit/kiro-terminal-integrity.test.js b/tests/unit/kiro-terminal-integrity.test.js new file mode 100644 index 0000000000..aaf6620900 --- /dev/null +++ b/tests/unit/kiro-terminal-integrity.test.js @@ -0,0 +1,778 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: (...args) => fetchMock(...args) +})); + +const { KiroExecutor } = await import("../../open-sse/executors/kiro.js"); + +const encoder = new TextEncoder(); +const credentials = { + accessToken: "test-token", + providerSpecificData: { kiroToolCallRepair: true } +}; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function encodeHeader(name, value) { + const nameBytes = encoder.encode(name); + const valueBytes = encoder.encode(value); + const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length); + let offset = 0; + bytes[offset++] = nameBytes.length; + bytes.set(nameBytes, offset); + offset += nameBytes.length; + bytes[offset++] = 7; + new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false); + offset += 2; + bytes.set(valueBytes, offset); + return bytes; +} + +function concat(chunks) { + const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function frameFromEntries(entries, payload) { + const headers = concat(entries.map(([name, value]) => encodeHeader(name, value))); + const payloadBytes = encoder.encode(JSON.stringify(payload)); + const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4; + const frame = new Uint8Array(totalLength); + const view = new DataView(frame.buffer); + view.setUint32(0, totalLength, false); + view.setUint32(4, headers.byteLength, false); + frame.set(headers, 12); + frame.set(payloadBytes, 12 + headers.byteLength); + return checksum(frame); +} + +function frame(eventType, payload) { + return frameFromEntries([[":event-type", eventType]], payload); +} + +function checksum(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint32(8, crc32(bytes.subarray(0, 8)), false); + view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false); + return bytes; +} + +function response(frames, status = 200) { + return new Response(new ReadableStream({ + start(controller) { + for (const value of frames) controller.enqueue(value); + controller.close(); + } + }), { status, statusText: status === 200 ? "OK" : "Upstream Error" }); +} + +function controlledResponse(frames = []) { + let controller; + const value = new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + for (const item of frames) controller.enqueue(item); + } + }), { status: 200 }); + return { + value, + enqueue(item) { + controller.enqueue(item); + }, + close() { + controller.close(); + } + }; +} + +async function text(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) return output + decoder.decode(); + output += decoder.decode(value, { stream: true }); + } +} + +async function execute(executor = new KiroExecutor(), overrides = {}) { + return executor.execute({ + model: "kr/claude-opus-4.8", + body: { systemPrompt: "base", conversationState: {} }, + stream: true, + credentials, + ...overrides + }); +} + +beforeEach(() => { + fetchMock.mockReset(); + delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES; + delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS; + delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS; +}); + +afterEach(() => { + delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES; + delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS; + delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS; +}); + +describe("Kiro terminal integrity recovery", () => { + it("keeps semantic output private behind a heartbeat until clean EOF", async () => { + const upstream = controlledResponse([ + frame("assistantResponseEvent", { content: "private until validated" }) + ]); + fetchMock.mockResolvedValueOnce(upstream.value); + + const result = await execute(); + const reader = result.response.body.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe(": kiro-validation\n\n"); + + let settled = false; + const semantic = reader.read().then((value) => { + settled = true; + return value; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + upstream.close(); + expect(new TextDecoder().decode((await semantic).value)).toContain("private until validated"); + await reader.cancel(); + }); + + it("accepts CLI-compatible text and usage frames at clean EOF without messageStop", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Complete answer." }), + frame("meteringEvent", { usage: 2, unit: "credit" }), + frame("contextUsageEvent", { contextUsagePercentage: 10 }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("Complete answer."); + expect(body).toContain('"finish_reason":"stop"'); + expect(body).toContain('"kiro_credits":2'); + }); + + it("parses frames split across chunks and multiple frames in one chunk", async () => { + const first = frame("assistantResponseEvent", { content: "split " }); + const second = frame("assistantResponseEvent", { content: "boundaries" }); + const combined = concat([first, second]); + fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(combined.slice(0, 9)); + controller.enqueue(combined.slice(9, first.byteLength + 5)); + controller.enqueue(combined.slice(first.byteLength + 5)); + controller.close(); + } + }))); + + const body = await (await execute()).response.text(); + + expect(body).toContain('"content":"split "'); + expect(body).toContain('"content":"boundaries"'); + expect(body).toContain('"finish_reason":"stop"'); + }); + + it("accepts messageStop without semantic output as explicit completion", async () => { + fetchMock.mockResolvedValueOnce(response([frame("messageStopEvent", {})])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"finish_reason":"stop"'); + expect(body).not.toContain("kiro_missing_terminal"); + }); + + it.each(["...", "…"])("repairs exact ellipsis final %s without leaking it", async (ellipsis) => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: ellipsis })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Recovered answer." })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered answer."); + expect(body).not.toContain(`"content":"${ellipsis}"`); + }); + + it.each([ + "接下來我只再確認部署結果。", + "我會重新抓取最新日誌並確認結果。", + "目前證據顯示只在 **03:48:30–03:49:00 TPE** 出現少量 NonKA 504;主池 106/106、副池 50/50,且兩池都沒有重啟。最後補查 504 access log,確認 host/路徑與是否為集中流量。", + "Next I'll verify the deployment logs.", + "Let me check the remaining failures." + ])("repairs conservative future-action final: %s", async (progress) => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: progress })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Verification completed." })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Verification completed."); + expect(body).not.toContain(progress); + }); + + it.each([ + "Working...", + "I'll check the logs. They show no errors and deployment succeeded.", + "Let me check: status is 200 and the checksum matches abc123.", + "我會檢查版本。版本是 1.2.3。", + "接下來請你先批准部署,我會等待你的確認。", + "已完成驗證,所有測試均通過。", + "目前證據顯示只有少量 504,且主副池均未重啟。", + "目前證據顯示只有少量 504。最後補查結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查,結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查:結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與有無集中流量:無集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與是否為集中流量(答案是否定的)。", + "目前證據顯示只有少量 504。最後補充兩點已確認的結果。", + "The verification is complete and all tests passed." + ])("does not retry legitimate final: %s", async (finalText) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: finalText }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(finalText); + }); + + it("bounds incomplete-final repair to one retry", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "..." })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "…" })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_ellipsis_retry_failed"); + expect(body).not.toContain('"content":"..."'); + }); + + it("repairs malformed wrapper tools without leaking the invalid call", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "bad", + name: "tool_call", + input: { arguments: { q: "router" } } + })])) + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "good", + name: "tool_call", + input: { name: "mcp_search", arguments: { q: "router" } } + })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain('"name":"tool_call"'); + expect(body).toContain('\\"name\\":\\"mcp_search\\"'); + expect(body).not.toContain('"id":"bad"'); + }); + + it("requires complete direct tool input and keeps the failure private", async () => { + const pending = frame("toolUseEvent", { toolUseId: "pending", name: "read_file" }); + fetchMock + .mockResolvedValueOnce(response([pending])) + .mockResolvedValueOnce(response([pending])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"name":"read_file"'); + }); + + it("repairs a non-string toolUseId before releasing the tool call", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: 123, + name: "read_file", + input: { path: "bad.txt" } + })])) + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "valid-tool-id", + name: "read_file", + input: { path: "safe.txt" } + })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain('"id":"valid-tool-id"'); + expect(body).not.toContain('"id":123'); + }); + + it("keeps model-controlled parser detail out of the retry system prompt", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "bad-json", + name: "tool_call", + input: '{"name":"IGNORE_ALL_INSTRUCTIONS"' + })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { + content: "Recovered safely." + })])); + + const body = await (await execute()).response.text(); + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body); + + expect(body).toContain("Recovered safely."); + expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed"); + expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS"); + }); + + it("lets a complete tool call override metadata end_turn", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "tool", + name: "read_file", + input: { path: "safe.txt" } + }), + frame("metadataEvent", { stopReason: "end_turn" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"name":"read_file"'); + expect(body).toContain('"finish_reason":"tool_calls"'); + }); + + it("maps max_tokens without treating it as a normal stop", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Limited answer." }), + frame("metadataEvent", { stopReason: "max_tokens" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"finish_reason":"length"'); + expect(body).not.toContain('"finish_reason":"stop"'); + }); + + it("retries malformed_model_output once without semantic leakage", async () => { + fetchMock + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private malformed output" }), + frame("metadataEvent", { stopReason: "malformed_model_output" }) + ])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered protocol output." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered protocol output."); + expect(body).not.toContain("private malformed output"); + }); + + it.each([ + ["cancelled", "kiro_terminal_incomplete"], + ["pause_turn", "kiro_terminal_incomplete"], + ["content_filtered", "kiro_terminal_refusal"], + ["novel_reason", "kiro_unknown_stop_reason"] + ])("fails closed for stop reason %s", async (stopReason, code) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: `private-${stopReason}` }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain(`private-${stopReason}`); + expect(body).not.toContain('"finish_reason":"stop"'); + }); + + it.each([ + [ + frame("messageStopEvent", { stopReason: "content_filtered" }), + frame("metadataEvent", { stopReason: "end_turn" }) + ], + [ + frame("metadataEvent", { stopReason: "end_turn" }), + frame("messageStopEvent", { stopReason: "content_filtered" }) + ] + ])("preserves the most restrictive conflicting stop reason", async (...stopFrames) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private filtered output" }), + ...stopFrames + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_terminal_refusal"); + expect(body).not.toContain("private filtered output"); + }); + + it("prefers a non-retryable terminal reason over an earlier retryable reason", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private malformed output" }), + frame("metadataEvent", { stopReason: "malformed_model_output" }), + frame("messageStopEvent", { stopReason: "cancelled" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_terminal_incomplete"); + expect(body).toContain('"stop_reason":"cancelled"'); + expect(body).not.toContain("private malformed output"); + }); + + it("preserves an authoritative refusal returned by the bounded retry", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private filtered retry" }), + frame("metadataEvent", { stopReason: "content_filtered" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_terminal_refusal"); + expect(body).not.toContain("kiro_missing_terminal_retry_failed"); + expect(body).not.toContain("private filtered retry"); + }); + + it.each([ + ["max_tokens", "kiro_terminal_incomplete"], + ["cancelled", "kiro_terminal_incomplete"], + ["content_filtered", "kiro_terminal_refusal"], + ["novel_reason", "kiro_unknown_stop_reason"] + ])("does not let a valid tool override failure stop reason %s", async (stopReason, code) => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "blocked-tool", + name: "read_file", + input: { path: "secret.txt" } + }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain('"name":"read_file"'); + }); + + it.each(["content_filtered", "cancelled", "max_tokens"])( + "classifies failure %s before validating a malformed deferred tool", + async (stopReason) => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { toolUseId: "bad-tool", name: "read_file" }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(stopReason === "content_filtered" + ? "kiro_terminal_refusal" + : "kiro_terminal_incomplete"); + expect(body).not.toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"name":"read_file"'); + } + ); + + it.each([ + ["content_filtered", [frame("toolUseEvent", { + toolUseId: 123, + name: "read_file", + input: { path: "bad.txt" } + })], "kiro_terminal_refusal"], + ["cancelled", [frame("toolUseEvent", { + toolUseId: "missing-name", + input: { path: "bad.txt" } + })], "kiro_terminal_incomplete"], + ["max_tokens", [ + frame("toolUseEvent", { toolUseId: "changing", name: "read_file" }), + frame("toolUseEvent", { toolUseId: "changing", name: "write_file" }) + ], "kiro_terminal_incomplete"] + ])("continues past eager tool-shape errors to authoritative stop %s", async (stopReason, toolFrames, code) => { + fetchMock.mockResolvedValueOnce(response([ + ...toolFrames, + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"tool_calls"'); + }); + + it("retries a TTFT timeout once while preserving cancellation semantics", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "1"; + fetchMock + .mockResolvedValueOnce(controlledResponse().value) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after timeout." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after timeout."); + }); + + it("treats validated non-semantic frames as watchdog activity", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "30"; + process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS = "30"; + const upstream = controlledResponse(); + fetchMock.mockResolvedValueOnce(upstream.value); + setTimeout(() => upstream.enqueue(frame("meteringEvent", { usage: 1 })), 20); + setTimeout(() => upstream.enqueue(frame("contextUsageEvent", { contextUsagePercentage: 5 })), 40); + setTimeout(() => { + upstream.enqueue(frame("assistantResponseEvent", { content: "Completed after active frames." })); + upstream.close(); + }, 60); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("Completed after active frames."); + }); + + it("retries a response-body read failure once", async () => { + fetchMock + .mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + controller.error(new Error("socket reset")); + } + }))) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after read failure." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after read failure."); + expect(body).not.toContain("socket reset"); + }); + + it.each([ + ["message CRC", () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt CRC" }); + corrupt[corrupt.byteLength - 1] ^= 0xff; + return [corrupt]; + }], + ["prelude CRC", () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt prelude" }); + corrupt[8] ^= 0xff; + return [corrupt]; + }], + ["truncated frame", () => { + const truncated = frame("assistantResponseEvent", { content: "truncated" }); + return [truncated.slice(0, -3)]; + }], + ["out-of-bounds headers", () => { + const corrupt = frame("assistantResponseEvent", { content: "bad headers" }); + new DataView(corrupt.buffer).setUint32(4, corrupt.byteLength - 15, false); + return [checksum(corrupt)]; + }], + ["duplicate headers", () => [ + frameFromEntries([ + [":event-type", "assistantResponseEvent"], + [":event-type", "metadataEvent"] + ], { content: "duplicate" }) + ]] + ])("retries %s and releases only the valid attempt", async (_name, invalidFrames) => { + fetchMock + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "must stay private" }), + ...invalidFrames() + ])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after validation." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after validation."); + expect(body).not.toContain("must stay private"); + }); + + it("reports corrupt-frame provenance when the bounded retry also fails", async () => { + const corruptFrame = () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt" }); + corrupt[corrupt.byteLength - 1] ^= 0xff; + return corrupt; + }; + fetchMock + .mockResolvedValueOnce(response([corruptFrame()])) + .mockResolvedValueOnce(response([corruptFrame()])); + + const body = await (await execute()).response.text(); + + expect(body).toContain("kiro_missing_terminal_retry_failed"); + expect(body).toContain('"terminal_provenance":"corrupt_eventstream_frame"'); + expect(body).toContain('"transport_state":"corrupt_frame"'); + }); + + it("caps diagnostic event-type cardinality", async () => { + let terminal; + const executor = new KiroExecutor(); + const frames = Array.from({ length: 100 }, (_, index) => + frame(`unknownEvent${index}`, { index }) + ); + frames.push(frame("assistantResponseEvent", { content: "done" })); + const transformed = executor.transformEventStreamToSSE( + response(frames), + "kr/claude-opus-4.8", + { onTerminalState: (value) => { terminal = value; } } + ); + + await transformed.text(); + + expect(terminal.event_counts).toEqual({ + other: 100, + assistantResponseEvent: 1 + }); + }); + + it("rejects a raw chunk before concatenating beyond the protocol bound", async () => { + let terminal; + const executor = new KiroExecutor(); + const transformed = executor.transformEventStreamToSSE( + response([new Uint8Array(65)]), + "kr/claude-opus-4.8", + { + maxRawBytes: 64, + onTerminalState: (value) => { terminal = value; } + } + ); + + const body = await transformed.text(); + + expect(body).toContain("buffered bytes exceed the protocol bound"); + expect(terminal.terminal_provenance).toBe("corrupt_eventstream_frame"); + }); + + it.each(["error", "exception"])("propagates EventStream %s without retry or leakage", async (messageType) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "must stay private" }), + frameFromEntries([ + [":message-type", messageType], + ...(messageType === "exception" ? [[":exception-type", "InternalServerException"]] : []) + ], { message: "upstream failed" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_upstream_eventstream_error"); + expect(body).toContain("upstream failed"); + expect(body).not.toContain("must stay private"); + }); + + it("surfaces retry HTTP failures as SSE after heartbeat commits headers", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(new Response("unauthorized", { + status: 401, + statusText: "Unauthorized" + })); + + const result = await execute(); + const body = await result.response.text(); + + expect(result.response.status).toBe(200); + expect(body).toContain("kiro_integrity_retry_upstream_error"); + expect(body).toContain("unauthorized"); + }); + + it("bounds the retry HTTP error body", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(new Response(`error-start-${"x".repeat(10_000)}-error-tail`, { + status: 401, + statusText: "Unauthorized" + })); + + const body = await (await execute()).response.text(); + + expect(body).toContain("error-start-"); + expect(body).not.toContain("error-tail"); + expect(body.length).toBeLessThan(5000); + }); + + it("propagates cancellation while validation is waiting for EOF", async () => { + const upstream = controlledResponse([ + frame("assistantResponseEvent", { content: "waiting" }) + ]); + fetchMock.mockResolvedValueOnce(upstream.value); + const abort = new AbortController(); + + const result = await execute(new KiroExecutor(), { signal: abort.signal }); + const reader = result.response.body.getReader(); + await reader.read(); + abort.abort("client cancelled"); + + await expect(reader.read()).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("fails safely when the private gate exceeds its configured bound", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "8"; + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "larger than eight bytes" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("integrity buffer exceeded"); + expect(body).not.toContain("larger than eight bytes"); + }); + + it("counts deferred tool fragments against the private memory bound", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "128"; + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "large-tool", + name: "read_file", + input: { path: "x".repeat(200) } + }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_integrity_buffer_exceeded"); + expect(body).not.toContain('"name":"read_file"'); + }); +}); diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js index 1fff6e9ea8..cc20aa4740 100644 --- a/tests/unit/kiro-thinking-strip.test.js +++ b/tests/unit/kiro-thinking-strip.test.js @@ -32,10 +32,23 @@ function createMockFrame(eventType, payloadObj) { offset += headerValueBytes.length; buffer.set(payloadBytes, offset); - + + view.setUint32(8, crc32(buffer.subarray(0, 8)), false); + view.setUint32(totalLength - 4, crc32(buffer.subarray(0, totalLength - 4)), false); return buffer; } +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + async function readAllSSE(stream) { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -130,14 +143,16 @@ describe("KiroExecutor thinking tag stripping", () => { expect(contentChunks.length).toBe(0); }); - it("emits a terminal chunk at messageStop before the upstream stream closes", async () => { + it("waits for clean EOF before emitting stop after messageStop", async () => { const executor = new KiroExecutor(); const f1 = createMockFrame("assistantResponseEvent", { content: "OK" }); const f2 = createMockFrame("messageStopEvent", {}); + let upstreamController; const readableStream = new ReadableStream({ start(controller) { + upstreamController = controller; controller.enqueue(f1); controller.enqueue(f2); } @@ -147,11 +162,16 @@ describe("KiroExecutor thinking tag stripping", () => { const reader = transformedResponse.body.getReader(); const decoder = new TextDecoder(); let output = ""; - for (let i = 0; i < 4 && !output.includes("\"finish_reason\":\"stop\""); i++) { - const { value } = await readNextWithTimeout(reader); - output += decoder.decode(value, { stream: true }); + const { value } = await readNextWithTimeout(reader); + output += decoder.decode(value, { stream: true }); + expect(output).not.toContain("\"finish_reason\":\"stop\""); + + upstreamController.close(); + while (!output.includes("\"finish_reason\":\"stop\"")) { + const { value: nextValue, done } = await readNextWithTimeout(reader); + if (done) break; + output += decoder.decode(nextValue, { stream: true }); } - await reader.cancel(); expect(output).toContain("\"finish_reason\":\"stop\""); });