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,