diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index d40a7833..6e2f803b 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -122,7 +122,8 @@ export class KiroExecutor extends BaseExecutor { hasReasoningContent: false, reasoningChunkCount: 0, toolCallIndex: 0, - seenToolIds: new Map() + seenToolIds: new Map(), + inThinking: false }; const transformStream = new TransformStream({ @@ -159,7 +160,36 @@ export class KiroExecutor extends BaseExecutor { // Handle assistantResponseEvent if (eventType === "assistantResponseEvent" && event.payload?.content) { - const content = 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 = { diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js new file mode 100644 index 00000000..91b5009c --- /dev/null +++ b/tests/unit/kiro-thinking-strip.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; + +function createMockFrame(eventType, payloadObj) { + const payloadStr = JSON.stringify(payloadObj); + const payloadBytes = new TextEncoder().encode(payloadStr); + + const headerName = ":event-type"; + const headerNameBytes = new TextEncoder().encode(headerName); + const headerValueBytes = new TextEncoder().encode(eventType); + + // nameLen(1) + name + type(1) + valueLen(2) + value + const headerLength = 1 + headerNameBytes.length + 1 + 2 + headerValueBytes.length; + const totalLength = 12 + headerLength + payloadBytes.length + 4; + + const buffer = new Uint8Array(totalLength); + const view = new DataView(buffer.buffer); + + view.setUint32(0, totalLength, false); + view.setUint32(4, headerLength, false); + + let offset = 12; + buffer[offset++] = headerNameBytes.length; + buffer.set(headerNameBytes, offset); + offset += headerNameBytes.length; + + buffer[offset++] = 7; // String type + view.setUint16(offset, headerValueBytes.length, false); + offset += 2; + buffer.set(headerValueBytes, offset); + offset += headerValueBytes.length; + + buffer.set(payloadBytes, offset); + + return buffer; +} + +async function readAllSSE(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + return result; +} + +describe("KiroExecutor thinking tag stripping", () => { + it("strips tags from assistantResponseEvent", async () => { + const executor = new KiroExecutor(); + + // Create frames + const f1 = createMockFrame("assistantResponseEvent", { content: "Here is my answer. Let me think..." }); + const f2 = createMockFrame("assistantResponseEvent", { content: "still thinking... Yes, 42." }); + + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(f1); + controller.enqueue(f2); + controller.close(); + } + }); + + const mockResponse = { body: readableStream }; + const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test"); + + const output = await readAllSSE(transformedResponse.body); + + // Check that we got chat.completion.chunk outputs + expect(output).toContain("chat.completion.chunk"); + // Ensure the thinking parts are gone + expect(output).not.toContain(""); + expect(output).not.toContain("Let me think..."); + expect(output).not.toContain("still thinking..."); + expect(output).not.toContain(""); + + // Check that the normal content is preserved + // Parse the data chunks + const dataLines = output.split("\n").filter(line => line.startsWith("data: ")); + const contents = dataLines.map(line => { + if (line.includes("[DONE]")) return ""; + try { + return JSON.parse(line.slice(6)).choices[0].delta.content || ""; + } catch { + return ""; + } + }); + + const fullText = contents.join(""); + expect(fullText).toBe("Here is my answer. Yes, 42."); + }); + + it("handles empty content after stripping when hasReasoningContent is true", async () => { + const executor = new KiroExecutor(); + + const f0 = createMockFrame("reasoningContentEvent", { text: "I am reasoning" }); + const f1 = createMockFrame("assistantResponseEvent", { content: "purely thinking..." }); + + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(f0); + controller.enqueue(f1); + controller.close(); + } + }); + + const mockResponse = { body: readableStream }; + const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test"); + + const output = await readAllSSE(transformedResponse.body); + + const dataLines = output.split("\n").filter(line => line.startsWith("data: ") && !line.includes("[DONE]")); + const objects = dataLines.map(line => JSON.parse(line.slice(6))); + + // First chunk should have reasoning_content + expect(objects[0].choices[0].delta.reasoning_content).toBe("I am reasoning"); + + // We shouldn't get an empty content chunk from f1 since it was entirely stripped and reasoning was present + const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined); + expect(contentChunks.length).toBe(0); + }); +});