feat: add commentary text emission to Claude CLI streaming parser

Detect text accumulated before tool_use blocks in the Claude CLI
streaming parser and emit it as commentary via a new onCommentaryText
callback. This enables the same commentary progress display that the
Codex backend already provides through preamble item events.

- Add onCommentaryText optional callback to createCliJsonlStreamingParser
- Flush accumulated assistantText as commentary when content_block_start
  with tool_use type is encountered
- Track last flushed position to avoid duplicate emissions on consecutive
  tool_use blocks without intervening text
- Wire the callback in both execute.ts (regular CLI spawn + live session)
  and claude-live-session.ts, emitting AgentItemEventData with
  kind=preamble and progressText
- Add 3 test cases covering: text before tool_use, empty text before
  tool_use, and consecutive tool_use dedup
This commit is contained in:
Cameron Beeley
2026-06-08 21:13:22 +05:30
committed by Ayaan Zaidi
parent c1300455d9
commit d03952ccd4
4 changed files with 187 additions and 0 deletions
+137
View File
@@ -893,4 +893,141 @@ describe("createCliJsonlStreamingParser", () => {
},
]);
});
it("fires onCommentaryText with accumulated text before a tool_use block", () => {
const commentaryTexts: string[] = [];
const deltas: Array<{ text: string; delta: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: (delta) => deltas.push({ text: delta.text, delta: delta.delta }),
onCommentaryText: (text) => commentaryTexts.push(text),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-commentary" }),
// Text delta accumulating assistant commentary
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Let me check " },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "that for you." },
},
}),
// Tool use block starts -- should flush commentary
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(commentaryTexts).toEqual(["Let me check that for you."]);
// Assistant deltas should still have been emitted normally
expect(deltas).toHaveLength(2);
expect(deltas[1]?.text).toBe("Let me check that for you.");
});
it("does not fire onCommentaryText when no text precedes tool_use", () => {
const commentaryTexts: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onCommentaryText: (text) => commentaryTexts.push(text),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-no-commentary" }),
// Tool use block starts immediately without preceding text
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(commentaryTexts).toEqual([]);
});
it("does not duplicate commentary when consecutive tool_use blocks have no new text", () => {
const commentaryTexts: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onCommentaryText: (text) => commentaryTexts.push(text),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-multi-commentary" }),
// First commentary
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "First, checking files." },
},
}),
// First tool_use -- should flush commentary
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_1", name: "Read", input: {} },
},
}),
// Second tool_use immediately (no new text) -- should NOT duplicate
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 2,
content_block: { type: "tool_use", id: "toolu_2", name: "Bash", input: {} },
},
}),
].join("\n") + "\n",
);
parser.finish();
// Commentary fires only once for the first tool_use. The second tool_use
// has no new accumulated text, so it does not fire again.
expect(commentaryTexts).toEqual(["First, checking files."]);
});
});
+28
View File
@@ -632,12 +632,14 @@ export function createCliJsonlStreamingParser(params: {
onAssistantDelta: (delta: CliStreamingDelta) => void;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onCommentaryText?: (text: string) => void;
}) {
let lineBuffer = "";
let assistantText = "";
let sessionId: string | undefined;
let usage: CliUsage | undefined;
let output: CliOutput | null = null;
let lastFlushedCommentaryLength = 0;
const texts: string[] = [];
const toolTracker = createToolUseTracker();
@@ -677,6 +679,32 @@ export function createCliJsonlStreamingParser(params: {
}
}
// Flush accumulated assistant text as commentary when a tool_use block starts.
// Text preceding a tool call is inter-tool commentary, not final answer text.
// Only flush if new text has arrived since the last flush to avoid duplicates
// when multiple tool_use blocks appear consecutively without intervening text.
if (
params.onCommentaryText &&
usesClaudeStreamJsonDialect(params) &&
parsed.type === "stream_event" &&
isRecord(parsed.event)
) {
const evt = parsed.event;
if (
evt.type === "content_block_start" &&
isRecord(evt.content_block) &&
isClaudeToolUseBlockType(evt.content_block.type)
) {
if (assistantText.length > lastFlushedCommentaryLength) {
const trimmedCommentary = assistantText.trim();
if (trimmedCommentary) {
params.onCommentaryText(trimmedCommentary);
}
lastFlushedCommentaryLength = assistantText.length;
}
}
}
if (params.onToolUseStart || params.onToolResult) {
dispatchClaudeCliStreamingToolEvent({
backend: params.backend,
@@ -1122,6 +1122,7 @@ function createTurn(params: {
onAssistantDelta: (delta: CliStreamingDelta) => void;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onCommentaryText?: (text: string) => void;
session: ClaudeLiveSession;
execPermission: ClaudeLiveExecPermission;
resolve: (output: CliOutput) => void;
@@ -1148,6 +1149,7 @@ function createTurn(params: {
onAssistantDelta: params.onAssistantDelta,
onToolUseStart: params.onToolUseStart,
onToolResult: params.onToolResult,
onCommentaryText: params.onCommentaryText,
}),
execPermission: params.execPermission,
resolve: params.resolve,
@@ -1218,6 +1220,7 @@ export async function runClaudeLiveSessionTurn(params: {
onAssistantDelta: (delta: CliStreamingDelta) => void;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onCommentaryText?: (text: string) => void;
cleanup: () => Promise<void>;
}): Promise<ClaudeLiveRunResult> {
const key = buildClaudeLiveKey(params.context);
@@ -1335,6 +1338,7 @@ export async function runClaudeLiveSessionTurn(params: {
onAssistantDelta: params.onAssistantDelta,
onToolUseStart: params.onToolUseStart,
onToolResult: params.onToolResult,
onCommentaryText: params.onCommentaryText,
session: liveSession,
execPermission,
resolve,
+18
View File
@@ -494,6 +494,22 @@ export async function executePreparedCliRun(
},
});
};
let commentaryCounter = 0;
const emitCliCommentaryText = (text: string) => {
commentaryCounter += 1;
emitAgentEvent({
runId: params.runId,
stream: "item",
data: {
kind: "preamble",
itemId: `commentary-${params.runId}-${commentaryCounter}`,
phase: "update",
title: "commentary",
status: "running",
progressText: text,
},
});
};
if (shouldUseClaudeLiveSession(context)) {
if (!hasJsonlOutput) {
throw new Error("Claude live session requires JSONL streaming parser");
@@ -536,6 +552,7 @@ export async function executePreparedCliRun(
},
onToolUseStart: emitCliToolUseStart,
onToolResult: emitCliToolResult,
onCommentaryText: emitCliCommentaryText,
cleanup: async () => {
try {
await fallbackClaudeSkillsPlugin?.cleanup();
@@ -580,6 +597,7 @@ export async function executePreparedCliRun(
},
onToolUseStart: emitCliToolUseStart,
onToolResult: emitCliToolResult,
onCommentaryText: emitCliCommentaryText,
})
: null;
const supervisor = executeDeps.getProcessSupervisor();