Skip to content

Commit 3c2316c

Browse files
fix(agents): preserve streamed assistant text when Claude CLI result event is empty (#90450)
* fix(agents): preserve streamed Claude CLI replies Co-authored-by: Luiz Antonio Busnello <[email protected]> * fix: preserve Claude live commentary boundaries * docs(changelog): stabilize Claude CLI entry * chore(changelog): defer Claude CLI entry --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: Luiz Antonio Busnello <[email protected]>
1 parent a6e19fe commit 3c2316c

4 files changed

Lines changed: 198 additions & 9 deletions

File tree

src/agents/cli-output.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,52 @@ describe("parseCliJsonl", () => {
780780
});
781781
});
782782

783+
it("preserves streamed Claude text when the final result text is empty", () => {
784+
const result = parseCliJsonl(
785+
[
786+
JSON.stringify({ type: "init", session_id: "session-456" }),
787+
JSON.stringify({
788+
type: "stream_event",
789+
event: {
790+
type: "content_block_delta",
791+
delta: { type: "text_delta", text: "Hello" },
792+
},
793+
}),
794+
JSON.stringify({
795+
type: "stream_event",
796+
event: {
797+
type: "content_block_delta",
798+
delta: { type: "text_delta", text: " world" },
799+
},
800+
}),
801+
JSON.stringify({
802+
type: "result",
803+
session_id: "session-456",
804+
result: "",
805+
usage: { input_tokens: 18, output_tokens: 4 },
806+
}),
807+
].join("\n"),
808+
{
809+
command: "claude",
810+
output: "jsonl",
811+
sessionIdFields: ["session_id"],
812+
},
813+
"claude-cli",
814+
);
815+
816+
expect(result).toEqual({
817+
text: "Hello world",
818+
sessionId: "session-456",
819+
usage: {
820+
input: 18,
821+
output: 4,
822+
cacheRead: undefined,
823+
cacheWrite: undefined,
824+
total: undefined,
825+
},
826+
});
827+
});
828+
783829
it("unwraps nested Claude agent result JSON from stream-json output", () => {
784830
const result = parseCliJsonl(
785831
[
@@ -1028,6 +1074,48 @@ describe("createCliJsonlStreamingParser", () => {
10281074
});
10291075
});
10301076

1077+
it("preserves streamed Claude text when the final result event is empty", () => {
1078+
const parser = createCliJsonlStreamingParser({
1079+
backend: {
1080+
command: "local-cli",
1081+
output: "jsonl",
1082+
jsonlDialect: "claude-stream-json",
1083+
sessionIdFields: ["session_id"],
1084+
},
1085+
providerId: "local-cli",
1086+
onAssistantDelta: () => {},
1087+
});
1088+
1089+
parser.push(
1090+
[
1091+
JSON.stringify({ type: "init", session_id: "session-stream" }),
1092+
JSON.stringify({
1093+
type: "stream_event",
1094+
event: {
1095+
type: "content_block_delta",
1096+
delta: { type: "text_delta", text: "hello" },
1097+
},
1098+
}),
1099+
JSON.stringify({
1100+
type: "stream_event",
1101+
event: {
1102+
type: "content_block_delta",
1103+
delta: { type: "text_delta", text: " world" },
1104+
},
1105+
}),
1106+
JSON.stringify({ type: "result", session_id: "session-stream", result: "" }),
1107+
"",
1108+
].join("\n"),
1109+
);
1110+
parser.finish();
1111+
1112+
expect(parser.getOutput()).toEqual({
1113+
text: "hello world",
1114+
sessionId: "session-stream",
1115+
usage: undefined,
1116+
});
1117+
});
1118+
10311119
it("reports an output-limit error and ignores later chunks", () => {
10321120
const parser = createCliJsonlStreamingParser({
10331121
backend: {

src/agents/cli-output.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,7 +1164,12 @@ export function createCliJsonlStreamingParser(params: {
11641164
usage,
11651165
});
11661166
if (result) {
1167-
output = result;
1167+
// The terminal result can be empty after Claude already streamed text.
1168+
// Keep that delivered text; a genuinely empty turn still remains empty.
1169+
output =
1170+
result.text || result.errorText
1171+
? result
1172+
: { ...result, text: assistantText.trim() || texts.join("\n").trim() };
11681173
return;
11691174
}
11701175

@@ -1412,7 +1417,12 @@ export function parseCliJsonl(
14121417
usage,
14131418
});
14141419
if (claudeResult) {
1415-
return claudeResult;
1420+
if (claudeResult.text || claudeResult.errorText) {
1421+
return claudeResult;
1422+
}
1423+
// Live sessions reparse the completed JSONL transcript, so preserve
1424+
// streamed text here as well as in the incremental parser above.
1425+
return { ...claudeResult, text: streamJsonText.trim() || texts.join("\n").trim() };
14161426
}
14171427

14181428
const claudeDelta = parseClaudeCliStreamingDelta({

src/agents/cli-runner.spawn.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ function buildPreparedCliRunContext(params: {
117117
skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"];
118118
thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"];
119119
executionMode?: PreparedCliRunContext["params"]["executionMode"];
120+
emitCommentaryText?: boolean;
120121
workspaceDir?: string;
121122
timeoutMs?: number;
122123
}): PreparedCliRunContext {
@@ -187,6 +188,7 @@ function buildPreparedCliRunContext(params: {
187188
model: params.model,
188189
thinkLevel: params.thinkLevel,
189190
executionMode: params.executionMode,
191+
emitCommentaryText: params.emitCommentaryText,
190192
timeoutMs: params.timeoutMs ?? 1_000,
191193
runId: params.runId,
192194
skillsSnapshot: params.skillsSnapshot,
@@ -1259,6 +1261,91 @@ describe("runCliAgent spawn path", () => {
12591261
}
12601262
});
12611263

1264+
it("keeps pre-tool commentary out of an empty-result Claude live reply", async () => {
1265+
const agentEvents: Array<{ stream: string; data: unknown }> = [];
1266+
const stop = onAgentEvent((event) => {
1267+
agentEvents.push({ stream: event.stream, data: event.data });
1268+
});
1269+
let stdoutListener: ((chunk: string) => void) | undefined;
1270+
const stdin = {
1271+
write: vi.fn((_data: string, callback?: (error?: Error | null) => void) => {
1272+
stdoutListener?.(
1273+
[
1274+
JSON.stringify({ type: "system", subtype: "init", session_id: "live-empty-result" }),
1275+
JSON.stringify({
1276+
type: "stream_event",
1277+
event: {
1278+
type: "content_block_delta",
1279+
delta: { type: "text_delta", text: "Let me check." },
1280+
},
1281+
}),
1282+
JSON.stringify({
1283+
type: "stream_event",
1284+
event: {
1285+
type: "content_block_start",
1286+
index: 1,
1287+
content_block: { type: "tool_use", id: "tool-1", name: "Read", input: {} },
1288+
},
1289+
}),
1290+
JSON.stringify({
1291+
type: "stream_event",
1292+
event: {
1293+
type: "content_block_delta",
1294+
delta: { type: "text_delta", text: "Final answer." },
1295+
},
1296+
}),
1297+
JSON.stringify({
1298+
type: "result",
1299+
session_id: "live-empty-result",
1300+
result: "",
1301+
}),
1302+
].join("\n") + "\n",
1303+
);
1304+
callback?.();
1305+
}),
1306+
end: vi.fn(),
1307+
};
1308+
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
1309+
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
1310+
stdoutListener = input.onStdout;
1311+
return {
1312+
runId: "live-empty-result-run",
1313+
pid: 2345,
1314+
startedAtMs: Date.now(),
1315+
stdin,
1316+
wait: vi.fn(() => new Promise(() => {})),
1317+
cancel: vi.fn(),
1318+
};
1319+
});
1320+
1321+
try {
1322+
const result = await executePreparedCliRun(
1323+
buildPreparedCliRunContext({
1324+
provider: "claude-cli",
1325+
model: "sonnet",
1326+
runId: "run-live-empty-result",
1327+
emitCommentaryText: true,
1328+
backend: { liveSession: "claude-stdio" },
1329+
}),
1330+
);
1331+
1332+
expect(result.text).toBe("Final answer.");
1333+
expect(agentEvents).toContainEqual({
1334+
stream: "item",
1335+
data: expect.objectContaining({
1336+
kind: "preamble",
1337+
progressText: "Let me check.",
1338+
}),
1339+
});
1340+
expect(agentEvents).toContainEqual({
1341+
stream: "assistant",
1342+
data: { text: "Final answer.", delta: "Final answer." },
1343+
});
1344+
} finally {
1345+
stop();
1346+
}
1347+
});
1348+
12621349
it("keeps non-capture live prepared backend cleanup with the whole-run owner", async () => {
12631350
let stdoutListener: ((chunk: string) => void) | undefined;
12641351
const stdin = {

src/agents/cli-runner/claude-live-session.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -876,13 +876,17 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void {
876876
return;
877877
}
878878
const raw = turn.rawLines.join("\n");
879-
const output = parseCliOutput({
880-
raw,
881-
backend: turn.backend,
882-
providerId: session.providerId,
883-
outputMode: "jsonl",
884-
fallbackSessionId: turn.sessionId,
885-
});
879+
// Reuse the parser that classified pre-tool text as commentary. Reparsing the
880+
// transcript loses that boundary when Claude's terminal result is empty.
881+
const output =
882+
turn.streamingParser.getOutput() ??
883+
parseCliOutput({
884+
raw,
885+
backend: turn.backend,
886+
providerId: session.providerId,
887+
outputMode: "jsonl",
888+
fallbackSessionId: turn.sessionId,
889+
});
886890
if (output.errorText) {
887891
failTurn(session, createParsedOutputError(session, output));
888892
scheduleIdleClose(session);

0 commit comments

Comments
 (0)