mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
committed by
Ayaan Zaidi
parent
c1300455d9
commit
d03952ccd4
@@ -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."]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user