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);
+ });
+});