Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions packages/harness-runner/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
brokenStudioMcp,
buildOptions,
createDeltaCoalescer,
errorFinishChunks,
isTransientProviderRejection,
mcpServersFor,
promptForRun,
promptFromUserMessage,
} from "./claude-code";
import { UiChunkTranslator } from "./to-ui-chunks";

/** A minimal valid wire input; each test overrides what it cares about. */
function input(
Expand Down Expand Up @@ -500,3 +502,43 @@ describe("createDeltaCoalescer", () => {
expect(c.push([odd])).toEqual([odd]);
});
});

describe("errorFinishChunks", () => {
test("closes a block stream_event left open before finish-step", () => {
const translator = new UiChunkTranslator();
translator.translate({
type: "stream_event",
event: { type: "message_start" },
});
translator.translate({
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
});
translator.translate({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "hi" },
},
});
// No `content_block_stop` — the SDK threw mid-block.
expect(errorFinishChunks(translator)).toEqual([
{ type: "text-end", id: "stream-1" },
{ type: "finish-step" },
{ type: "finish", finishReason: "error" },
]);
});

test("nothing left open: just finish-step and finish", () => {
const translator = new UiChunkTranslator();
expect(errorFinishChunks(translator)).toEqual([
{ type: "finish-step" },
{ type: "finish", finishReason: "error" },
]);
});
});
25 changes: 21 additions & 4 deletions packages/harness-runner/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,11 +830,28 @@ export async function runClaudeCode(
}
startTurn(messageId ?? `msg_${Date.now()}`);
emit({
chunks: [
{ type: "finish-step" },
{ type: "finish", finishReason: "error" },
],
chunks: errorFinishChunks(translator),
error,
});
}
}

/**
* Chunks for an aborted turn: close whatever `stream_event` left open, then
* finish the step and the run.
*
* Same orphan-end hazard the step boundary above guards against — the AI SDK
* reducer clears its open text/reasoning parts on `finish-step`, so an end
* emitted after it throws and drops the stream. An SDK throw mid-block (a
* network drop, a crash) reaches `fail()` with a block `stream_event` opened
* and never closed; without this, `finish-step` went out first and the part
* was left open forever instead of throwing — no crash, but a message that
* reads as still streaming after the run has already failed.
*/
export function errorFinishChunks(translator: UiChunkTranslator): unknown[] {
return [
...translator.closeOpenStreamBlocks(),
{ type: "finish-step" },
{ type: "finish", finishReason: "error" },
];
}
Loading