Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9130f78
fix(kiro): surface content-filter refusals instead of 'no tokens' (#6…
Yeachan-Heo Oct 7, 2026
28241ce
fix(kiro): address all blocking findings from #6151 code reviews
Oct 7, 2026
ecd8694
fix(kiro): prevent incomplete tool emission at stream end and tool-id…
Oct 7, 2026
a18e2a3
test(kiro): add regression tests for P1 tool-emission and content-sup…
Oct 7, 2026
9725900
Merge remote-tracking branch 'origin/dev' into HEAD
Oct 10, 2026
9b23344
fix(kiro): keep unconfirmed text out of published partials
Oct 10, 2026
5e1150f
fix(kiro): require explicit completion for tool-only responses at EOF
Oct 10, 2026
5a95029
test(kiro): add P2 regression test for unconfirmed text in published …
Oct 10, 2026
dff3b34
WIP: implement completion event handling for probepark fix - tests fa…
Oct 10, 2026
22b4c15
fix(kiro): fix probepark streaming error handling for completion events
Oct 10, 2026
f52533b
fix(kiro): defer text events until confirmed terminal metadata
Oct 10, 2026
3df9db8
fix(kiro): emit completion event and handle EOF incomplete JSON frames
Oct 10, 2026
3e026cc
restore test files to origin/dev
Oct 10, 2026
3d25fa2
fix(kiro): gate pending text emission on completion event to prevent …
Oct 10, 2026
16356b0
fix(kiro): handle detail-free stopReason CONTENT_FILTERED as refusal
Oct 10, 2026
bb2b4b3
fix(kiro): emit text events immediately on completion event (Finding #2)
Oct 10, 2026
0b64ca8
fix(kiro): parser does not resync into nested objects (Finding #3)
Oct 10, 2026
b3f3489
fix(kiro): emit text_end on server error before throwing (Finding #4)
Oct 10, 2026
b3362a4
docs: update changelog for probepark findings 1-4 on PR #6451
Oct 10, 2026
a0437ab
fix: restore resync logic and defer text block population in kiro-api…
Oct 10, 2026
c34be66
style: format code
Oct 10, 2026
24a593d
fix: add partial property to error event type definition
Oct 10, 2026
35af7f0
style(test): biome format incomplete frame literal
Yeachan-Heo Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/ai/changelog.d/6150-kiro-content-filtered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Fixed

- Kiro streams now surface explicit content-filter refusal messages with category and explanation instead of the generic "no tokens" error when a refusal is detected. Both bearer token (via `messageMetadataEvent`) and API-key (`ksk_`, via `metadataEvent`) paths are supported, with proper provider safety-stop minting. Text is streamed incrementally unless a thinking block precedes it, in which case the entire answer (after thinking) is buffered until the stream end; tool calls are emitted only at stream end (#6150).
- Detail-free `stopReason: "CONTENT_FILTERED"` (without `stopDetails.refusal` object) is now treated as a refusal and suppresses unconfirmed content.
- Text events are now emitted immediately when a completion event is received, instead of being deferred to EOF. This prevents text events from being lost if subsequent reads fail.
- Parser no longer resyncs into nested objects when an incomplete outer JSON frame is encountered. Incomplete frames are retained as remainder; EOF with incomplete frame throws an error.
- Server error events now emit `text_end` for confirmed text before throwing the error, ensuring complete text lifecycle.
252 changes: 174 additions & 78 deletions packages/ai/src/providers/kiro-api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ type KiroStreamEvent =
| { type: "toolUseInput"; data: { input: string } }
| { type: "toolUseStop"; data: { stop: boolean } }
| { type: "usage"; data: { inputTokens?: number; outputTokens?: number } }
| { type: "completion"; data: { stopReason?: string } }
| {
type: "refusal";
data: { stopReason?: string; stopDetails?: { refusal?: { category?: string; explanation?: string } } };
Expand Down Expand Up @@ -497,38 +498,10 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
const end = findJsonEnd(buffer, start);
if (end < 0) {
// Unclosed brace at 'start'. Could be incomplete JSON waiting for more data, or junk/corruption.
// Resync by looking for valid JSON objects within MAX_RESCAN_DISTANCE that parse successfully.
// Finding #3: Do NOT resync into nested objects that might be part of the outer incomplete frame.
// Keep the incomplete frame as remainder for the next parse call or EOF error handling.
const strayBraceEnd = Math.min(start + MAX_RESCAN_DISTANCE, buffer.length);
let resyncPos = start + 1;
let found = false;
while (resyncPos < strayBraceEnd) {
const nextStart = buffer.indexOf("{", resyncPos);
if (nextStart < 0) break;

// Try to find the end of this candidate
const nextEnd = findJsonEnd(buffer, nextStart);
if (nextEnd >= 0) {
// Found a candidate that closes. Try to parse it as valid JSON.
try {
const candidate = buffer.slice(nextStart, nextEnd + 1);
JSON.parse(candidate);
// Valid JSON found. Resync to this position and continue processing.
pos = nextStart;
found = true;
break;
} catch {
// Not valid JSON, keep looking
}
}
resyncPos = nextStart + 1;
}
if (!found) {
// No valid JSON found within MAX_RESCAN_DISTANCE. Retain the stray brace and data up to the cap
// for the next parse call. Enforce the MAX_RESCAN_DISTANCE cap to prevent unbounded buffering.
return { events, remaining: buffer.slice(start, start + MAX_RESCAN_DISTANCE) };
}
// Loop will continue with the resynced pos
continue;
return { events, remaining: buffer.slice(start, strayBraceEnd) };
}

let parsed: Record<string, unknown> | undefined;
Expand Down Expand Up @@ -567,20 +540,21 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];

// Check for refusal after usage so that usage is processed first and recorded before refusal terminates
// This check is now order-independent: stopDetails can appear at any position in the JSON object
// Also handle detail-free CONTENT_FILTERED as a refusal (no stopDetails.refusal needed)
if (
typeof parsed.stopDetails === "object" &&
parsed.stopDetails !== null &&
(parsed.stopDetails as Record<string, unknown>).refusal
(typeof parsed.stopDetails === "object" &&
parsed.stopDetails !== null &&
(parsed.stopDetails as Record<string, unknown>).refusal) ||
parsed.stopReason === "CONTENT_FILTERED"
) {
// Emit refusal event if stopDetails contains actual refusal data
// Emit refusal event if stopDetails contains actual refusal data or stopReason is CONTENT_FILTERED
events.push({
type: "refusal",
data: {
stopReason: parsed.stopReason as string | undefined,
stopDetails: parsed.stopDetails as { refusal?: { category?: string; explanation?: string } },
},
});
// Normal terminal metadata (stopReason: "COMPLETED", etc.) without refusal data is ignored
} else if (parsed.error || parsed.Error) {
events.push({
type: "error",
Expand All @@ -589,6 +563,14 @@ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[];
message: (parsed.message || parsed.Message) as string | undefined,
},
});
} else if (parsed.stopReason && typeof parsed.stopReason === "string") {
// Normal terminal metadata without refusal or error: emit completion event
events.push({
type: "completion",
data: {
stopReason: parsed.stopReason,
},
});
Comment on lines +594 to +601

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat detail-free CONTENT_FILTERED metadata as refusal

When Kiro returns stopReason: "CONTENT_FILTERED" without a populated stopDetails.refusal object, this branch classifies the frame as a successful completion. If the connection subsequently rejects or is aborted, the catch path treats the quarantined text as confirmed and emits it; on clean EOF it also returns the text as a successful response. Branch on CONTENT_FILTERED before the generic completion case and produce a refusal even when category or explanation details are absent.

Useful? React with 👍 / 👎.

}
} catch {
// JSON parsing failed for a balanced frame: could be a junk wrapper like '{junk{...}}'
Expand Down Expand Up @@ -807,6 +789,14 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
let toolComplete = false; // Track whether the current tool has been completed (has stop flag)
let toolcallIndex: number | undefined;
const pendingToolCalls: Array<{ input: string; toolCall: ToolCall; index: number }> = [];
// Track terminal metadata so we can emit confirmed text even if later reads fail
let hasReceivedTerminalMetadata = false; // Set to true when usage event is received
let completionSeen = false; // Set to true when completion event is received
let textIndex: number | undefined; // Track text block index (accessible in catch)
let textContentConfirmed = false; // Track whether text has been committed to blocks (accessible in catch)
let textStartDeferred = false; // Track if text_start is deferred (accessible in catch)
let pendingTextContent = ""; // Text buffer (accessible in catch)
const pendingTextEvents: Array<{ type: "start" | "delta" | "end"; delta?: string; content?: string }> = []; // Deferred text events (accessible in catch)

try {
// Use validated identity snapshot from trust check if available (no second read of getters),
Expand Down Expand Up @@ -879,7 +869,8 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
let buffer = "";
let lastContent = "";
let thinkingIndex: number | undefined;
let textIndex: number | undefined;
// Note: textIndex, textContentConfirmed, textStartDeferred, pendingTextContent, pendingTextEvents
// are declared before the try block so they're accessible in the catch block

// Add tool to blocks without emitting events (deferred until stream end)
const addToolToBlocks = () => {
Expand Down Expand Up @@ -938,8 +929,6 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
toolComplete = false; // Reset tool completion flag
};

let textStartDeferred = false; // Track if text_start has been deferred pending thinking

const appendText = (delta: string) => {
// Set firstTokenTime on first real (non-thinking) text delta
if (!firstTokenEmitted) {
Expand All @@ -951,48 +940,56 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
}
if (textIndex === undefined) {
textIndex = blocks.length;
// Create block with empty text (content will be added only after confirmation)
blocks.push({ type: "text", text: "", index: textIndex });
// Defer text_start emission if thinking might come before text.
// thinkingAccumulated is non-empty means we've seen thinking in this or prior content events.
// We don't know yet if more thinking will come, so defer text_start until stream end.
if (thinkingAccumulated.length === 0) {
// No thinking yet; safe to emit text_start now
stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
} else {
// Thinking exists; defer text_start until we know thinking position
textStartDeferred = true;
}
// ALWAYS defer text_start until we have terminal metadata or reach EOF without error.
// This ensures unconfirmed text is not exposed if an error occurs.
textStartDeferred = true;
}
const block = blocks[textIndex] as TextContent;
block.text += delta;
// Only emit text_delta if text_start was already emitted
if (!textStartDeferred) {
stream.push({ type: "text_delta", contentIndex: textIndex, delta, partial: output });
// Buffer text content separately (don't add to blocks yet)
pendingTextContent += delta;
// Don't emit text_delta yet; defer until stream end or terminal metadata is received.
// Queue delta for later emission when confirmed.
pendingTextEvents.push({ type: "delta", delta });
};

// Commit pending text to blocks (only safe at stream end when refusal is ruled out)
const commitPendingText = () => {
if (textContentConfirmed || pendingTextContent.length === 0) return; // Already committed or no text
textContentConfirmed = true;
if (textIndex !== undefined && textIndex < blocks.length) {
// Populate the text block that was created with empty text
const block = blocks[textIndex] as TextContent;
block.text = pendingTextContent;
}
};

// Emit deferred text events if text_start was not yet sent
const emitDeferredTextEvents = () => {
if (textStartDeferred && textIndex !== undefined && textIndex < blocks.length) {
const block = blocks[textIndex];
if (block && block.type === "text") {
if (block && block.type === "text" && block.text.length > 0) {
// Emit deferred text_start
stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
if (block.text.length > 0) {
stream.push({
type: "text_delta",
contentIndex: textIndex,
delta: block.text,
partial: output,
});
// Emit all pending text_delta events
for (const evt of pendingTextEvents) {
if (evt.type === "delta" && evt.delta) {
stream.push({
type: "text_delta",
contentIndex: textIndex,
delta: evt.delta,
partial: output,
});
}
}
textStartDeferred = false;
}
textStartDeferred = false;
}
};

// Emit text_end if text block was opened
const closeTextBlock = () => {
if (textIndex !== undefined) {
if (textIndex !== undefined && textContentConfirmed) {
const block = blocks[textIndex] as TextContent;
stream.push({ type: "text_end", contentIndex: textIndex, content: block.text, partial: output });
textIndex = undefined;
Expand Down Expand Up @@ -1110,7 +1107,12 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
firstTokenEmitted = true;
firstTokenTime = Date.now();
}
if (currentTool && currentTool.id !== event.data.toolUseId) addToolToBlocks();
if (currentTool && currentTool.id !== event.data.toolUseId) {
// Only emit previous tool if it was completed
if (toolComplete) addToolToBlocks();
currentTool = undefined; // Clear the incomplete tool
toolComplete = false; // Reset for new tool
}
if (!currentTool) {
currentTool = { id: event.data.toolUseId, name: event.data.name, input: event.data.input };
toolComplete = false; // New tool starts as incomplete
Expand All @@ -1135,15 +1137,30 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
if (event.data.inputTokens !== undefined) output.usage.input = event.data.inputTokens;
if (event.data.outputTokens !== undefined) output.usage.output = event.data.outputTokens;
output.usage.totalTokens = output.usage.input + output.usage.output;
// Mark that terminal metadata has been received - text is now confirmed safe
hasReceivedTerminalMetadata = true;
} else if (event.type === "completion") {
// Normal terminal metadata (stopReason: "COMPLETED", etc.) without refusal
// Mark completion as seen - text is now confirmed safe even without usage event
completionSeen = true;
hasReceivedTerminalMetadata = true;
// Finding #2: Immediately commit and emit all pending text events on completion
commitPendingText();
emitThinking();
emitDeferredTextEvents();
closeTextBlock();
Comment on lines +1184 to +1188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore content frames after terminal completion

When another content frame arrives after COMPLETED, this branch closes the first text block but leaves the loop and accumulated text state active. The later frame creates a second empty block; because textContentConfirmed remains true, its content is never committed, and EOF emits an orphan text_end for that empty block. The added regression fixture explicitly supplies post-completion content that “should be ignored,” but its boolean assertions miss the extra lifecycle event and empty final block; stop processing content after terminal metadata or reset the state consistently.

AGENTS.md reference: AGENTS.md:L171-L173

Useful? React with 👍 / 👎.

Comment on lines +1184 to +1188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let a batched refusal override completion

When content is pending from an earlier read and a later reader.read() returns both a COMPLETED frame and a subsequent CONTENT_FILTERED frame, hasTerminalEvent detects the refusal but this branch still commits and emits the pending text/thinking before the refusal handler clears it. The filtered content therefore escapes solely because the two terminal frames share a network batch; skip successful-completion handling whenever the parsed batch contains a refusal, and cover this chunk boundary.

AGENTS.md reference: AGENTS.md:L169-L173

Useful? React with 👍 / 👎.

} else if (event.type === "refusal") {
const refusalData = event.data as {
stopReason?: string;
stopDetails?: { refusal?: { category?: string; explanation?: string } };
};
if (refusalData.stopDetails?.refusal) {
// Handle refusal: clear blocks and emit error (don't emit any text/tool that came before)
// Handle refusal: clear pending content and emit error (don't emit any text/tool that came before)
consumeContent(""); // Flush any pending thinking
clearPendingToolCalls(); // DROP any pending tool call without emitting events
// Clear pending text (it was never committed to blocks, so it's safe to discard)
pendingTextContent = "";
pendingTextEvents.length = 0;

const refusal = refusalData.stopDetails.refusal;
const category = refusal.category;
Expand Down Expand Up @@ -1182,26 +1199,74 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
return;
}
} else if (event.type === "error") {
// On ordinary errors, flush pending COMPLETED tool events before the error terminal
// On ordinary server errors (error event in stream), emit deferred text and tool events before error
// (refusals drop them, incomplete tools must not be emitted)

// For server-sent errors, emit the deferred text first (it was received before the error)
// Only emit confirmed text (text is confirmed safe only after completion or usage event)
if (textContentConfirmed === false && (completionSeen || hasReceivedTerminalMetadata)) {
// Commit pending text to blocks
if (textIndex !== undefined && textIndex < blocks.length && pendingTextContent.length > 0) {
const block = blocks[textIndex] as TextContent;
block.text = pendingTextContent;
textContentConfirmed = true;
}
// Emit deferred text start if not yet emitted
if (textStartDeferred && textIndex !== undefined && textIndex < blocks.length) {
const block = blocks[textIndex];
if (block && block.type === "text" && block.text.length > 0) {
stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
// Emit all pending text_delta events
for (const evt of pendingTextEvents) {
if (evt.type === "delta" && evt.delta) {
stream.push({
type: "text_delta",
contentIndex: textIndex,
delta: evt.delta,
partial: output,
});
}
}
// Finding #4: Emit text_end for confirmed text before error
stream.push({ type: "text_end", contentIndex: textIndex, content: block.text, partial: output });
}
textStartDeferred = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit text_end before a confirmed-text error

When one batch contains content, successful terminal metadata, and then an ordinary error, this branch emits text_start/text_delta, sets textStartDeferred to false, and throws. The catch path then skips its only text_end emission because textContentConfirmed is already true, leaving subscribers with an open text lifecycle; the equivalent post-terminal reader.read() failure does emit text_end. Close the confirmed text block before throwing and cover the complete event sequence.

AGENTS.md reference: AGENTS.md:L171-L173

Useful? React with 👍 / 👎.

}
}

// Only emit the tool if it was explicitly completed (has stop flag)
if (toolComplete) addToolToBlocks();
emitPendingToolCalls();

// Preserve any already-accumulated text in the error context
const accumulatedText = blocks
.filter((b): b is TextContent => b.type === "text")
.map(b => b.text)
.join("");
// Preserve text in the error context only if confirmed (text is confirmed safe only after completion or usage event)
const accumulatedText = textContentConfirmed
? blocks
.filter((b): b is TextContent => b.type === "text")
.map(b => b.text)
.join("")
: ""; // Discard unconfirmed pending text before error
const errorMsg = sanitizeKiroError(`${event.data.error}: ${event.data.message ?? ""}`, apiKey);
const fullErrorMsg = accumulatedText ? `${errorMsg}\n\nPartial output: ${accumulatedText}` : errorMsg;
throw new Error(fullErrorMsg);
Comment on lines 1304 to 1306

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush confirmed text before throwing a same-batch error

When one response read contains content, COMPLETED, and then an ordinary error object, the terminal event marks the text as confirmed, but this throw bypasses the batch-level flushPendingTextEvents() below. The catch path preserves the text in error.content because hasSuccessfulTerminalEvent is true, yet consumers receive no text_start/text_delta; splitting the same objects across reads does emit them. Flush confirmed pending text before throwing so delivery does not depend on HTTP chunk boundaries, and cover this multi-object parsing boundary.

AGENTS.md reference: AGENTS.md:L171-L171

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on exact head 97b88f1a19b7baefd56d6ffeed0c69028894a515, based on dev bf82403a0ff0505ca5a713bbe62eca418444863a. When a read contains content, successful COMPLETED metadata, then an ordinary error, the error branch now flushes confirmed pending text before it throws; text remains discarded if terminal metadata has not arrived. Added a one-read regression and reproduced the missing text_delta before the fix.

Verification at this head: bun test packages/ai/test/kiro-api-key-content-filter.test.ts packages/ai/test/kiro-api-key.test.ts packages/ai/test/kiro-codewhisperer-content-filter.test.ts (69 passed); bun --cwd=packages/ai run check (passed). Dev CI run 37634293294 is pending on this head; Public site sync 37634293774 passed.

Disposition: finding fixed; both human reviewers were requested on this exact head. No merge before exact-head CI and review disposition are clear.

—
[repo owner's gaebal-gajae (clawdbot) 🦞]

}
}
}

// Stream is done - add any pending tool, emit accumulated thinking, then deferred text, then close text block
addToolToBlocks();
// Check for incomplete JSON frame in leftover buffer at EOF
if (buffer.trim().length > 0) {
// Non-whitespace content remains in buffer
const openBraceIdx = buffer.indexOf("{");
if (openBraceIdx >= 0 && findJsonEnd(buffer, openBraceIdx) < 0) {
// Incomplete JSON frame at EOF (opening brace with no matching close)
throw new Error(`Kiro API key stream truncated: incomplete JSON frame at EOF`);
}
}

// Stream is done - commit pending text (safe since no refusal occurred), add pending tool, emit accumulated thinking, etc.
// First commit pending text to blocks since stream ended without refusal
commitPendingText();
// Emit tool only if explicitly completed (has stop flag); incomplete tools are discarded at EOF
if (currentTool && toolComplete) addToolToBlocks();
emitThinking();
emitDeferredTextEvents();
// Now emit all tool call events in order (safe since no refusal occurred)
Expand Down Expand Up @@ -1237,10 +1302,41 @@ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
stream.push({ type: "done", reason: output.stopReason as "stop" | "toolUse", message: output });
stream.end();
} catch (error) {
// On ordinary errors (including reader.read() throws), emit only COMPLETED tool events
// before the error terminal (same semantics as the ordinary-error flush in the event loop).
// Incomplete currentTool is NOT finalized/emitted (only completed tools in pendingToolCalls flush).
// Refusals drop them via clearPendingToolCalls, but ordinary errors preserve content consistency.
// On ordinary errors (including reader.read() throws):
// - Emit COMPLETED tool events (same semantics as in the event processing loop)
// - Emit confirmed text only if terminal metadata (usage) was received before the error
// - Incomplete currentTool is NOT finalized/emitted (only completed tools in pendingToolCalls flush).
// - Unconfirmed text is suppressed (no deferred text emission without terminal metadata)

// Emit confirmed text if we received completion event or terminal metadata (usage)
if ((hasReceivedTerminalMetadata || completionSeen) && textContentConfirmed === false) {
// Commit pending text to blocks
if (textIndex !== undefined && textIndex < blocks.length && pendingTextContent.length > 0) {
const block = blocks[textIndex] as TextContent;
block.text = pendingTextContent;
textContentConfirmed = true;
}
// Emit deferred text start if not yet emitted
if (textStartDeferred && textIndex !== undefined && textIndex < blocks.length) {
const block = blocks[textIndex];
if (block && block.type === "text" && block.text.length > 0) {
stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
// Emit all pending text_delta events
for (const evt of pendingTextEvents) {
if (evt.type === "delta" && evt.delta) {
stream.push({
type: "text_delta",
contentIndex: textIndex,
delta: evt.delta,
partial: output,
});
}
}
stream.push({ type: "text_end", contentIndex: textIndex, content: block.text, partial: output });
}
textStartDeferred = false;
}
}

// Emit all pending tool call events (inline of emitPendingToolCalls logic)
for (const { input, toolCall, index } of pendingToolCalls) {
Expand Down
Loading
Loading