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.
This commit is contained in:
Ayaan Zaidi
2026-07-30 21:31:37 +09:00
committed by GitHub
parent 1cbe674d6a
commit 2ca340fa0a
5 changed files with 305 additions and 7 deletions

View File

@@ -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();
}
});
});

View File

@@ -39,6 +39,9 @@ export function createCliEventHandlers(params: {
let signaledAssistantOutputStarted = false;
let commentaryCounter = 0;
const toolSummaryById = new Map<string, { name: string; failed: boolean }>();
// 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<string, Record<string, unknown>>();
const toolSummaryNames: string[] = [];
const toolSummaryNameSet = new Set<string>();
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 } : {}),
},
});

View File

@@ -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<string, unknown> | 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,

View File

@@ -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<string, unknown>) {
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();
});
});

View File

@@ -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<string, unknown> | undefined {
@@ -10,6 +11,27 @@ function readRecordValue(value: unknown): Record<string, unknown> | 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,