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