Skip to content

Commit d7b9b21

Browse files
anagnorisis2peripeteiaobviyus
authored andcommitted
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).
1 parent 439dcbd commit d7b9b21

5 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/auto-reply/reply/agent-runner-cli-dispatch.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,32 @@ function createToolEventBridge(params: {
173173
});
174174
}
175175

176+
// Bridges CLI inter-tool commentary (assistant text emitted before a tool call, surfaced by the
177+
// parser as a `stream:"item", kind:"preamble"` agent event) into a channel callback. Without this,
178+
// CLI commentary lands on the agent-event bus with no subscriber and is silently dropped — the
179+
// tool/assistant/reasoning streams each have a bridge, but the item/preamble stream had none.
180+
function createCommentaryEventBridge(params: {
181+
runId: string;
182+
suppressed?: boolean;
183+
deliver?: (payload: { text: string; itemId?: string }) => Promise<void>;
184+
}) {
185+
return createAgentEventBridge({
186+
runId: params.runId,
187+
suppressed: params.suppressed,
188+
deliver: params.deliver,
189+
read: (evt) => {
190+
if (evt.stream !== "item" || evt.data.kind !== "preamble") {
191+
return undefined;
192+
}
193+
const text = typeof evt.data.progressText === "string" ? evt.data.progressText.trim() : "";
194+
if (!text) {
195+
return undefined;
196+
}
197+
return { text, itemId: typeof evt.data.itemId === "string" ? evt.data.itemId : undefined };
198+
},
199+
});
200+
}
201+
176202
export async function runCliAgentWithLifecycle(params: {
177203
runId: string;
178204
provider: string;
@@ -185,6 +211,7 @@ export async function runCliAgentWithLifecycle(params: {
185211
onAssistantText?: (text: string) => Promise<void>;
186212
onReasoningText?: (text: string) => Promise<void>;
187213
onToolEvent?: (payload: CliToolEventPayload) => Promise<void>;
214+
onCommentaryText?: (payload: { text: string; itemId?: string }) => Promise<void>;
188215
onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
189216
transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult;
190217
}): Promise<EmbeddedAgentRunResult> {
@@ -219,16 +246,23 @@ export async function runCliAgentWithLifecycle(params: {
219246
suppressed: params.suppressAssistantBridge,
220247
deliver: params.onToolEvent,
221248
});
249+
const commentaryBridge = createCommentaryEventBridge({
250+
runId: params.runId,
251+
suppressed: params.suppressAssistantBridge,
252+
deliver: params.onCommentaryText,
253+
});
222254
let lifecycleTerminalEmitted = false;
223255
try {
224256
const rawResult = await runCliAgent(params.runParams);
225257
const result = params.transformResult?.(rawResult) ?? rawResult;
226258
assistantBridge.unsubscribe();
227259
reasoningBridge.unsubscribe();
228260
toolBridge.unsubscribe();
261+
commentaryBridge.unsubscribe();
229262
await assistantBridge.drain();
230263
await reasoningBridge.drain();
231264
await toolBridge.drain();
265+
await commentaryBridge.drain();
232266

233267
const cliText = normalizeOptionalString(result.payloads?.[0]?.text);
234268
if (cliText) {
@@ -256,9 +290,11 @@ export async function runCliAgentWithLifecycle(params: {
256290
assistantBridge.unsubscribe();
257291
reasoningBridge.unsubscribe();
258292
toolBridge.unsubscribe();
293+
commentaryBridge.unsubscribe();
259294
await assistantBridge.drain();
260295
await reasoningBridge.drain();
261296
await toolBridge.drain();
297+
await commentaryBridge.drain();
262298
await params.onErrorBeforeLifecycle?.(err);
263299
if (emitLifecycleTerminal) {
264300
emitAgentEvent({

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2405,6 +2405,67 @@ describe("runAgentTurnWithFallback", () => {
24052405
expect(call?.args).toEqual({ command: "ls -la" });
24062406
});
24072407

2408+
it("bridges CLI commentary agent events into onItemEvent for live preview", async () => {
2409+
state.isCliProviderMock.mockReturnValue(true);
2410+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
2411+
result: await params.run("claude-cli", "claude-opus-4-6"),
2412+
provider: "claude-cli",
2413+
model: "claude-opus-4-6",
2414+
attempts: [],
2415+
}));
2416+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
2417+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
2418+
"../../infra/agent-events.js",
2419+
);
2420+
// Inter-tool commentary surfaces as a stream:"item", kind:"preamble" agent event.
2421+
realAgentEvents.emitAgentEvent({
2422+
runId: params.runId,
2423+
stream: "item",
2424+
data: {
2425+
kind: "preamble",
2426+
itemId: "commentary-1",
2427+
progressText: "Let me check the files.",
2428+
},
2429+
});
2430+
return { payloads: [{ text: "done" }], meta: {} };
2431+
});
2432+
2433+
const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
2434+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
2435+
const followupRun = createFollowupRun();
2436+
followupRun.run.provider = "claude-cli";
2437+
followupRun.run.model = "claude-opus-4-6";
2438+
2439+
await runAgentTurnWithFallback({
2440+
commandBody: "hi",
2441+
followupRun,
2442+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
2443+
opts: { onItemEvent },
2444+
typingSignals: createMockTypingSignaler(),
2445+
blockReplyPipeline: null,
2446+
blockStreamingEnabled: false,
2447+
resolvedBlockStreamingBreak: "message_end",
2448+
applyReplyToMode: (payload) => payload,
2449+
shouldEmitToolResult: () => true,
2450+
shouldEmitToolOutput: () => false,
2451+
pendingToolTasks: new Set(),
2452+
resetSessionAfterRoleOrderingConflict: async () => false,
2453+
isHeartbeat: false,
2454+
sessionKey: "main",
2455+
getActiveSessionEntry: () => undefined,
2456+
resolvedVerboseLevel: "off",
2457+
});
2458+
await new Promise((resolve) => {
2459+
setImmediate(resolve);
2460+
});
2461+
2462+
expect(onItemEvent).toHaveBeenCalledTimes(1);
2463+
const call = onItemEvent.mock.calls[0]?.[0];
2464+
expect(call?.kind).toBe("preamble");
2465+
expect(call?.progressText).toBe("Let me check the files.");
2466+
expect(call?.itemId).toBe("commentary-1");
2467+
});
2468+
24082469
it("does not bridge CLI tool deltas when silentExpected is set", async () => {
24092470
state.isCliProviderMock.mockReturnValue(true);
24102471
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2137,6 +2137,13 @@ export async function runAgentTurnWithFallback(params: {
21372137
}),
21382138
]);
21392139
},
2140+
onCommentaryText: async ({ text, itemId }) => {
2141+
await params.opts?.onItemEvent?.({
2142+
kind: "preamble",
2143+
progressText: text,
2144+
itemId,
2145+
});
2146+
},
21402147
onErrorBeforeLifecycle: async () => {
21412148
if (!rollbackFallbackCandidateSelection) {
21422149
return;

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,74 @@ describe("createFollowupRunner runtime config", () => {
13801380
expect(onToolStart).not.toHaveBeenCalled();
13811381
});
13821382

1383+
it("bridges queued CLI inter-tool commentary into onItemEvent for live preview", async () => {
1384+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
1385+
"../../infra/agent-events.js",
1386+
);
1387+
const runtimeConfig: OpenClawConfig = {
1388+
agents: {
1389+
defaults: {
1390+
cliBackends: {
1391+
"claude-cli": { command: "claude" },
1392+
},
1393+
models: {
1394+
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
1395+
},
1396+
},
1397+
},
1398+
};
1399+
const onItemEvent = vi.fn(async () => {});
1400+
runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
1401+
realAgentEvents.emitAgentEvent({
1402+
runId: params.runId,
1403+
stream: "item",
1404+
data: {
1405+
kind: "preamble",
1406+
itemId: "commentary-1",
1407+
progressText: "Let me check the files.",
1408+
},
1409+
});
1410+
return {
1411+
payloads: [{ text: "final" }],
1412+
meta: {
1413+
agentMeta: {
1414+
provider: "claude-cli",
1415+
model: "claude-opus-4-7",
1416+
},
1417+
},
1418+
};
1419+
});
1420+
1421+
const runner = createFollowupRunner({
1422+
opts: { onItemEvent },
1423+
typing: createMockTypingController(),
1424+
typingMode: "instant",
1425+
defaultModel: "anthropic/claude-opus-4-7",
1426+
});
1427+
1428+
await runner(
1429+
createQueuedRun({
1430+
originatingChannel: "telegram",
1431+
run: {
1432+
config: runtimeConfig,
1433+
provider: "anthropic",
1434+
model: "claude-opus-4-7",
1435+
messageProvider: "telegram",
1436+
sourceReplyDeliveryMode: "message_tool_only",
1437+
verboseLevel: "on",
1438+
},
1439+
}),
1440+
);
1441+
1442+
expect(onItemEvent).toHaveBeenCalledWith(
1443+
expect.objectContaining({
1444+
kind: "preamble",
1445+
progressText: "Let me check the files.",
1446+
itemId: "commentary-1",
1447+
}),
1448+
);
1449+
});
1450+
13831451
it("defers queued CLI attempt terminal lifecycle events until fallback settles", async () => {
13841452
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
13851453
"../../infra/agent-events.js",

src/auto-reply/reply/followup-runner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,17 @@ export function createFollowupRunner(params: {
784784
emitChannelProgress: shouldEmitToolResultProgress(),
785785
});
786786
},
787+
onCommentaryText: async ({ text, itemId }) => {
788+
await forwardFollowupProgressEvent({
789+
evt: {
790+
stream: "item",
791+
data: { kind: "preamble", progressText: text, itemId },
792+
},
793+
opts,
794+
detailMode: toolProgressDetail,
795+
emitChannelProgress: shouldEmitToolResultProgress(),
796+
});
797+
},
787798
transformResult:
788799
queued.currentInboundEventKind === "room_event"
789800
? (resultLocal) =>

0 commit comments

Comments
 (0)