fix(cli): bridge inter-tool commentary events to channel progress

Inter-tool commentary (assistant text emitted before a tool call, surfaced by
the CLI parser as a stream:"item", kind:"preamble" agent event) landed on the
agent-event bus with no subscriber and was silently dropped: runCliAgentWithLifecycle
bridges the assistant, reasoning, and tool streams to channel callbacks, but the
item/preamble stream had no bridge. Add createCommentaryEventBridge to forward it
to onItemEvent, so CLI commentary reaches the channel's commentary render hook.

This is the CLI-dispatch delivery half of the commentary feature: the parser
emission (claude-cli) feeds the bus; this bridge delivers it to the channel.
Addresses the claude-cli case of intermediate-text-lost (#87326 / #84486).
This commit is contained in:
Cameron Beeley
2026-06-08 18:06:18 +05:30
committed by Ayaan Zaidi
parent 439dcbde3b
commit d7b9b21fb8
5 changed files with 183 additions and 0 deletions
@@ -173,6 +173,32 @@ function createToolEventBridge(params: {
});
}
// Bridges CLI inter-tool commentary (assistant text emitted before a tool call, surfaced by the
// parser as a `stream:"item", kind:"preamble"` agent event) into a channel callback. Without this,
// CLI commentary lands on the agent-event bus with no subscriber and is silently dropped — the
// tool/assistant/reasoning streams each have a bridge, but the item/preamble stream had none.
function createCommentaryEventBridge(params: {
runId: string;
suppressed?: boolean;
deliver?: (payload: { text: string; itemId?: string }) => Promise<void>;
}) {
return createAgentEventBridge({
runId: params.runId,
suppressed: params.suppressed,
deliver: params.deliver,
read: (evt) => {
if (evt.stream !== "item" || evt.data.kind !== "preamble") {
return undefined;
}
const text = typeof evt.data.progressText === "string" ? evt.data.progressText.trim() : "";
if (!text) {
return undefined;
}
return { text, itemId: typeof evt.data.itemId === "string" ? evt.data.itemId : undefined };
},
});
}
export async function runCliAgentWithLifecycle(params: {
runId: string;
provider: string;
@@ -185,6 +211,7 @@ export async function runCliAgentWithLifecycle(params: {
onAssistantText?: (text: string) => Promise<void>;
onReasoningText?: (text: string) => Promise<void>;
onToolEvent?: (payload: CliToolEventPayload) => Promise<void>;
onCommentaryText?: (payload: { text: string; itemId?: string }) => Promise<void>;
onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult;
}): Promise<EmbeddedAgentRunResult> {
@@ -219,6 +246,11 @@ export async function runCliAgentWithLifecycle(params: {
suppressed: params.suppressAssistantBridge,
deliver: params.onToolEvent,
});
const commentaryBridge = createCommentaryEventBridge({
runId: params.runId,
suppressed: params.suppressAssistantBridge,
deliver: params.onCommentaryText,
});
let lifecycleTerminalEmitted = false;
try {
const rawResult = await runCliAgent(params.runParams);
@@ -226,9 +258,11 @@ export async function runCliAgentWithLifecycle(params: {
assistantBridge.unsubscribe();
reasoningBridge.unsubscribe();
toolBridge.unsubscribe();
commentaryBridge.unsubscribe();
await assistantBridge.drain();
await reasoningBridge.drain();
await toolBridge.drain();
await commentaryBridge.drain();
const cliText = normalizeOptionalString(result.payloads?.[0]?.text);
if (cliText) {
@@ -256,9 +290,11 @@ export async function runCliAgentWithLifecycle(params: {
assistantBridge.unsubscribe();
reasoningBridge.unsubscribe();
toolBridge.unsubscribe();
commentaryBridge.unsubscribe();
await assistantBridge.drain();
await reasoningBridge.drain();
await toolBridge.drain();
await commentaryBridge.drain();
await params.onErrorBeforeLifecycle?.(err);
if (emitLifecycleTerminal) {
emitAgentEvent({
@@ -2405,6 +2405,67 @@ describe("runAgentTurnWithFallback", () => {
expect(call?.args).toEqual({ command: "ls -la" });
});
it("bridges CLI commentary agent events into onItemEvent for live preview", async () => {
state.isCliProviderMock.mockReturnValue(true);
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
result: await params.run("claude-cli", "claude-opus-4-6"),
provider: "claude-cli",
model: "claude-opus-4-6",
attempts: [],
}));
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",
);
// Inter-tool commentary surfaces as a stream:"item", kind:"preamble" agent event.
realAgentEvents.emitAgentEvent({
runId: params.runId,
stream: "item",
data: {
kind: "preamble",
itemId: "commentary-1",
progressText: "Let me check the files.",
},
});
return { payloads: [{ text: "done" }], meta: {} };
});
const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const followupRun = createFollowupRun();
followupRun.run.provider = "claude-cli";
followupRun.run.model = "claude-opus-4-6";
await runAgentTurnWithFallback({
commandBody: "hi",
followupRun,
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
opts: { onItemEvent },
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(onItemEvent).toHaveBeenCalledTimes(1);
const call = onItemEvent.mock.calls[0]?.[0];
expect(call?.kind).toBe("preamble");
expect(call?.progressText).toBe("Let me check the files.");
expect(call?.itemId).toBe("commentary-1");
});
it("does not bridge CLI tool deltas when silentExpected is set", async () => {
state.isCliProviderMock.mockReturnValue(true);
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
@@ -2137,6 +2137,13 @@ export async function runAgentTurnWithFallback(params: {
}),
]);
},
onCommentaryText: async ({ text, itemId }) => {
await params.opts?.onItemEvent?.({
kind: "preamble",
progressText: text,
itemId,
});
},
onErrorBeforeLifecycle: async () => {
if (!rollbackFallbackCandidateSelection) {
return;
@@ -1380,6 +1380,74 @@ describe("createFollowupRunner runtime config", () => {
expect(onToolStart).not.toHaveBeenCalled();
});
it("bridges queued CLI inter-tool commentary into onItemEvent for live preview", async () => {
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",
);
const runtimeConfig: OpenClawConfig = {
agents: {
defaults: {
cliBackends: {
"claude-cli": { command: "claude" },
},
models: {
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
},
},
},
};
const onItemEvent = vi.fn(async () => {});
runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
realAgentEvents.emitAgentEvent({
runId: params.runId,
stream: "item",
data: {
kind: "preamble",
itemId: "commentary-1",
progressText: "Let me check the files.",
},
});
return {
payloads: [{ text: "final" }],
meta: {
agentMeta: {
provider: "claude-cli",
model: "claude-opus-4-7",
},
},
};
});
const runner = createFollowupRunner({
opts: { onItemEvent },
typing: createMockTypingController(),
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-7",
});
await runner(
createQueuedRun({
originatingChannel: "telegram",
run: {
config: runtimeConfig,
provider: "anthropic",
model: "claude-opus-4-7",
messageProvider: "telegram",
sourceReplyDeliveryMode: "message_tool_only",
verboseLevel: "on",
},
}),
);
expect(onItemEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: "preamble",
progressText: "Let me check the files.",
itemId: "commentary-1",
}),
);
});
it("defers queued CLI attempt terminal lifecycle events until fallback settles", async () => {
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",
+11
View File
@@ -784,6 +784,17 @@ export function createFollowupRunner(params: {
emitChannelProgress: shouldEmitToolResultProgress(),
});
},
onCommentaryText: async ({ text, itemId }) => {
await forwardFollowupProgressEvent({
evt: {
stream: "item",
data: { kind: "preamble", progressText: text, itemId },
},
opts,
detailMode: toolProgressDetail,
emitChannelProgress: shouldEmitToolResultProgress(),
});
},
transformResult:
queued.currentInboundEventKind === "room_event"
? (resultLocal) =>