From 2ca340fa0afe0dbf0ae2f23539b5beb85f4d1a77 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 30 Jul 2026 21:31:37 +0900 Subject: [PATCH] feat(agents): report CLI command outcomes in channel progress The CLI candidate bridged tool events itself: it forwarded starts without a toolCallId and returned early on the result phase, so a Claude CLI turn produced progress lines with no identity and no terminal outcome. A failed command rendered exactly like one that succeeded. The bridge now forwards the call id and projects results through the same buildCommandOutputFromToolResultEvent the embedded path uses. Two gaps had to close for that projection to see a CLI result: it only read structured records, where CLI backends report raw text plus is_error, and it needed a title or the terminal line would describe the output instead of the command, so the runner carries the started args onto its result event. A bare result with no content stays excluded, since runners that report an outcome send a separate command_output event. Modeled on the sibling t3code Claude adapter, which correlates each tool_result back to its in-flight tool by tool_use_id and emits a failed/completed status. Proven live on the real Claude CLI backend: two calls, two lines updating in place, the failing one marked failed. --- .../execute-events.tool-result-args.test.ts | 137 ++++++++++++++++++ src/agents/cli-runner/execute-events.ts | 9 ++ .../reply/agent-runner-cli-candidate.ts | 33 ++++- .../reply/agent-runner-command-output.test.ts | 90 ++++++++++++ .../reply/agent-runner-command-output.ts | 43 +++++- 5 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 src/agents/cli-runner/execute-events.tool-result-args.test.ts create mode 100644 src/auto-reply/reply/agent-runner-command-output.test.ts diff --git a/src/agents/cli-runner/execute-events.tool-result-args.test.ts b/src/agents/cli-runner/execute-events.tool-result-args.test.ts new file mode 100644 index 000000000000..4b4b04e907d4 --- /dev/null +++ b/src/agents/cli-runner/execute-events.tool-result-args.test.ts @@ -0,0 +1,137 @@ +// CLI backends report a tool result without repeating the request, so the +// terminal progress event has to carry the args the tool started with. +import { describe, expect, it, vi } from "vitest"; +import { type AgentEventRuntimePayload, onAgentEvent } from "../../infra/agent-events.js"; +import { createCliEventHandlers } from "./execute-events.js"; +import type { CliToolTracking } from "./execute-tool-tracking.js"; +import type { PreparedCliRunContext } from "./types.js"; + +function buildContext(runId: string): PreparedCliRunContext { + const backend = { + command: "claude", + args: [], + output: "jsonl" as const, + input: "stdin" as const, + serialize: true, + }; + return { + params: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "claude-cli", + model: "claude-haiku-4-5", + timeoutMs: 1_000, + runId, + }, + started: Date.now(), + workspaceDir: "/tmp", + backendResolved: { id: "claude-cli", config: backend, bundleMcp: false }, + preparedBackend: { backend, env: {} }, + reusableCliSession: { mode: "none" }, + hadSessionFile: false, + contextEngineConfig: {}, + modelId: "claude-haiku-4-5", + normalizedModel: "claude-haiku-4-5", + systemPrompt: "system", + systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], + bootstrapPromptWarningLines: [], + authEpochVersion: 2, + } as PreparedCliRunContext; +} + +function buildToolTracking(): CliToolTracking { + return { + handleCliToolUseStart: vi.fn(), + handleCliToolResult: vi.fn(), + resolveCliLoopbackTerminalOutcome: vi.fn(() => undefined), + beginGatewayCapture: vi.fn(), + } as unknown as CliToolTracking; +} + +function collectToolEvents(runId: string): { + events: AgentEventRuntimePayload[]; + dispose: () => void; +} { + const events: AgentEventRuntimePayload[] = []; + const dispose = onAgentEvent((event) => { + if (event.runId === runId && event.stream === "tool") { + events.push(event); + } + }); + return { events, dispose }; +} + +describe("cli tool result events", () => { + it("carries the started args into the terminal event", () => { + const runId = "run-tool-result-args"; + const handlers = createCliEventHandlers({ + context: buildContext(runId), + toolTracking: buildToolTracking(), + getRunState: () => ({ failed: false, error: undefined }), + }); + const { events, dispose } = collectToolEvents(runId); + + try { + handlers.emitCliToolUseStart({ + toolCallId: "call-1", + name: "Bash", + kind: "tool_use", + args: { command: "nope-not-a-command" }, + }); + handlers.emitCliToolResult({ + toolCallId: "call-1", + name: "Bash", + isError: true, + result: "bash: nope-not-a-command: command not found", + }); + + const result = events.find((event) => event.data.phase === "result"); + expect(result?.data.args).toEqual({ command: "nope-not-a-command" }); + expect(result?.data.isError).toBe(true); + } finally { + dispose(); + } + }); + + it("forgets a call's args once it reports, so ids cannot leak across calls", () => { + const runId = "run-tool-result-args-forget"; + const handlers = createCliEventHandlers({ + context: buildContext(runId), + toolTracking: buildToolTracking(), + getRunState: () => ({ failed: false, error: undefined }), + }); + const { events, dispose } = collectToolEvents(runId); + + try { + handlers.emitCliToolUseStart({ + toolCallId: "call-1", + name: "Bash", + kind: "tool_use", + args: { command: "first" }, + }); + handlers.emitCliToolResult({ + toolCallId: "call-1", + name: "Bash", + isError: false, + result: "", + }); + // A second result for the same id must not reuse the first call's request. + handlers.emitCliToolResult({ + toolCallId: "call-1", + name: "Bash", + isError: false, + result: "", + }); + + const results = events.filter((event) => event.data.phase === "result"); + expect(results[0]?.data.args).toEqual({ command: "first" }); + expect(results[1]?.data.args).toBeUndefined(); + } finally { + dispose(); + } + }); +}); diff --git a/src/agents/cli-runner/execute-events.ts b/src/agents/cli-runner/execute-events.ts index 5c4a50f40f07..2fe6e3f148a8 100644 --- a/src/agents/cli-runner/execute-events.ts +++ b/src/agents/cli-runner/execute-events.ts @@ -39,6 +39,9 @@ export function createCliEventHandlers(params: { let signaledAssistantOutputStarted = false; let commentaryCounter = 0; const toolSummaryById = new Map(); + // CLI results report an outcome without repeating the request, so the terminal + // progress event would otherwise describe the output instead of the command. + const toolArgsByCallId = new Map>(); const toolSummaryNames: string[] = []; const toolSummaryNameSet = new Set(); const activeParsedTools = new Map< @@ -53,6 +56,9 @@ export function createCliEventHandlers(params: { toolSummaryNames.push(name); }; const recordToolStart = (event: CliToolUseStartDelta) => { + if (event.args && Object.keys(event.args).length > 0) { + toolArgsByCallId.set(event.toolCallId, event.args); + } const current = toolSummaryById.get(event.toolCallId); if (!current) { toolSummaryById.set(event.toolCallId, { name: event.name, failed: false }); @@ -112,6 +118,8 @@ export function createCliEventHandlers(params: { const resultContentSource = context.resultContentSourceByToolName?.get( stripOpenClawMcpToolPrefix(event.name), ); + const startedArgs = toolArgsByCallId.get(event.toolCallId); + toolArgsByCallId.delete(event.toolCallId); emitAgentEvent({ runId: runParams.runId, stream: "tool", @@ -121,6 +129,7 @@ export function createCliEventHandlers(params: { toolCallId: event.toolCallId, isError: event.isError, result: sanitizeToolResult(event.result), + ...(startedArgs ? { args: startedArgs } : {}), ...(resultContentSource ? { resultContentSource } : {}), }, }); diff --git a/src/auto-reply/reply/agent-runner-cli-candidate.ts b/src/auto-reply/reply/agent-runner-cli-candidate.ts index a1824811ca84..7e4cce175fab 100644 --- a/src/auto-reply/reply/agent-runner-cli-candidate.ts +++ b/src/auto-reply/reply/agent-runner-cli-candidate.ts @@ -31,6 +31,7 @@ import { keepCliSessionBindingOnlyWhenReused, runCliAgentWithLifecycle, } from "./agent-runner-cli-dispatch.js"; +import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js"; import type { AgentTurnParams } from "./agent-runner-execution.types.js"; import type { createAgentTurnPresentation } from "./agent-runner-presentation.js"; import type { AgentTurnTimingTracker } from "./agent-runner-turn-timing.js"; @@ -128,6 +129,30 @@ export async function runCliFallbackCandidate(params: { await turn.opts?.onToolResult?.(payload); }, }); + // CLI backends report a tool's outcome on the result event and never repeat it, + // so the terminal fact has to be projected here. The embedded path gets this + // from the shared agent-event handler; without it a failed CLI command renders + // exactly like one that succeeded. + const deliverCliCommandOutcome = async (payload: { + name: string | undefined; + phase: "start" | "update" | "result"; + args: Record | undefined; + toolCallId?: string; + isError?: boolean; + result?: unknown; + }) => { + const onCommandOutput = turn.opts?.onCommandOutput; + if (!onCommandOutput) { + return; + } + const commandOutput = buildCommandOutputFromToolResultEvent({ + stream: "tool", + data: { ...payload }, + }); + if (commandOutput) { + await onCommandOutput(commandOutput); + } + }; const bridgeCliPreambleProgress = Boolean(turn.opts?.onItemEvent) && shouldBridgeCliPreambleEvents(turn.opts); const bridgeCliDurableCommentary = @@ -212,12 +237,14 @@ export async function runCliFallbackCandidate(params: { if (!params.preserveProgressCallbackStartOrder) { await cliToolSummaryTracker.noteToolEvent(payload); if (payload.phase === "result") { + await deliverCliCommandOutcome(payload); return; } - const { name, phase, args } = payload; + const { name, phase, args, toolCallId } = payload; await Promise.all([ turn.typingSignals.signalToolStart(), turn.opts?.onToolStart?.({ + ...(toolCallId ? { toolCallId } : {}), name, phase, args, @@ -229,9 +256,10 @@ export async function runCliFallbackCandidate(params: { const summaryPromise = cliToolSummaryTracker.noteToolEvent(payload); if (payload.phase === "result") { await summaryPromise; + await deliverCliCommandOutcome(payload); return; } - const { name, phase, args } = payload; + const { name, phase, args, toolCallId } = payload; // Tool and assistant bridges drain independently. Preserve source order. await Promise.all([ summaryPromise, @@ -239,6 +267,7 @@ export async function runCliFallbackCandidate(params: { turn.typingSignals.signalToolStart(), () => turn.opts?.onToolStart?.({ + ...(toolCallId ? { toolCallId } : {}), name, phase, args, diff --git a/src/auto-reply/reply/agent-runner-command-output.test.ts b/src/auto-reply/reply/agent-runner-command-output.test.ts new file mode 100644 index 000000000000..359ac0b88996 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-command-output.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js"; + +const BASH_ARGS = { command: "nope-not-a-command", description: "run missing binary" }; + +function buildFromCliResult(overrides: Record) { + return buildCommandOutputFromToolResultEvent({ + stream: "tool", + data: { phase: "result", name: "Bash", toolCallId: "call-1", args: BASH_ARGS, ...overrides }, + }); +} + +describe("buildCommandOutputFromToolResultEvent", () => { + it("reports a CLI command failure whose result is only text", () => { + // CLI backends report the outcome plus raw content, never a structured + // record, so requiring a structured field dropped the failure entirely. + const built = buildFromCliResult({ + isError: true, + result: "bash: nope-not-a-command: command not found", + }); + + expect(built?.status).toBe("failed"); + expect(built?.output).toBe("bash: nope-not-a-command: command not found"); + }); + + it("reads the outcome from streamed text blocks", () => { + const built = buildFromCliResult({ + isError: true, + result: [ + { type: "text", text: "line one" }, + { type: "text", text: "line two" }, + ], + }); + + expect(built?.status).toBe("failed"); + expect(built?.output).toBe("line one\nline two"); + }); + + it("describes the command that ran instead of what it printed", () => { + const built = buildFromCliResult({ isError: true, result: "some noisy stderr" }); + + // The title drives the visible progress line; without it the line would + // replace the request with the tool's output. + expect(built?.title).toContain("nope-not-a-command"); + }); + + it("marks a successful CLI command completed", () => { + expect(buildFromCliResult({ isError: false, result: "alpha" })?.status).toBe("completed"); + }); + + it("prefers an explicit status and structured fields when present", () => { + const built = buildCommandOutputFromToolResultEvent({ + stream: "tool", + data: { + phase: "result", + name: "exec", + toolCallId: "call-1", + title: "false", + isError: true, + result: { exitCode: 2, output: "structured output", status: "exit 2" }, + }, + }); + + expect(built).toMatchObject({ + status: "exit 2", + exitCode: 2, + output: "structured output", + title: "false", + }); + }); + + it("ignores events that carry no outcome and no content", () => { + expect( + buildCommandOutputFromToolResultEvent({ + stream: "tool", + data: { phase: "result", name: "Bash", toolCallId: "call-1" }, + }), + ).toBeUndefined(); + }); + + it("ignores non-command tools and non-result phases", () => { + expect( + buildCommandOutputFromToolResultEvent({ + stream: "tool", + data: { phase: "result", name: "Read", toolCallId: "call-1", isError: true }, + }), + ).toBeUndefined(); + expect(buildFromCliResult({ phase: "start", isError: true })).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-command-output.ts b/src/auto-reply/reply/agent-runner-command-output.ts index 83c44c851efc..7875616cd2ba 100644 --- a/src/auto-reply/reply/agent-runner-command-output.ts +++ b/src/auto-reply/reply/agent-runner-command-output.ts @@ -2,6 +2,7 @@ import { normalizeLowercaseStringOrEmpty, readStringValue, } from "@openclaw/normalization-core/string-coerce"; +import { inferToolMetaFromArgs } from "../../agents/embedded-agent-utils.js"; import type { GetReplyOptions } from "../types.js"; function readRecordValue(value: unknown): Record | undefined { @@ -10,6 +11,27 @@ function readRecordValue(value: unknown): Record | undefined { : undefined; } +/** + * CLI backends report a tool result as its raw content: a string, or the text + * blocks the harness streamed. Structured runners send a record instead, so the + * command projection has to read both or every CLI command result is dropped. + */ +function readToolResultText(value: unknown): string | undefined { + const direct = readStringValue(value); + if (direct !== undefined) { + return direct; + } + if (!Array.isArray(value)) { + return undefined; + } + const text = value + .map((block) => readStringValue(readRecordValue(block)?.text)) + .filter((part): part is string => part !== undefined) + .join("\n") + .trim(); + return text || undefined; +} + function readFiniteNumberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -35,7 +57,7 @@ export function buildCommandOutputFromToolResultEvent(evt: { return undefined; } const name = readStringValue(evt.data.name); - if (!isCommandToolName(name)) { + if (!name || !isCommandToolName(name)) { return undefined; } const result = readRecordValue(evt.data.result); @@ -43,7 +65,8 @@ export function buildCommandOutputFromToolResultEvent(evt: { const output = readStringValue(evt.data.output) ?? readStringValue(result?.output) ?? - readStringValue(details?.output); + readStringValue(details?.output) ?? + readToolResultText(evt.data.result); const explicitStatus = readStringValue(evt.data.status) ?? readStringValue(result?.status) ?? @@ -55,6 +78,12 @@ export function buildCommandOutputFromToolResultEvent(evt: { result?.durationMs ?? details?.durationMs ?? evt.data.durationMs, ); const cwd = readStringValue(evt.data.cwd); + const errorStatus = + evt.data.isError === true ? "failed" : evt.data.isError === false ? "completed" : undefined; + // A bare result carries no outcome of its own: runners that report one send a + // separate command_output event, and synthesizing here would duplicate it. + // A CLI result is different because its content *is* the outcome, which + // readToolResultText surfaces as output above. const hasConcreteCommandResult = output !== undefined || explicitStatus !== undefined || @@ -65,12 +94,16 @@ export function buildCommandOutputFromToolResultEvent(evt: { if (!hasConcreteCommandResult) { return undefined; } - const errorStatus = - evt.data.isError === true ? "failed" : evt.data.isError === false ? "completed" : undefined; + // Keep the line describing the command, not its output: without a title the + // terminal line would replace the request with whatever the tool printed. + const args = readRecordValue(evt.data.args); + const title = + readStringValue(evt.data.title) ?? + (args ? inferToolMetaFromArgs(name, args, { detailMode: "explain" }) : undefined); return { itemId: readStringValue(evt.data.itemId), phase: "end", - title: readStringValue(evt.data.title), + title, toolCallId: readStringValue(evt.data.toolCallId), name, output,