mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 14:31:40 +00:00
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.
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
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 {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: 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;
|
|
}
|
|
|
|
function readNullableNumberValue(value: unknown): number | null | undefined {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
return readFiniteNumberValue(value);
|
|
}
|
|
|
|
function isCommandToolName(name: string | undefined): boolean {
|
|
const normalized = normalizeLowercaseStringOrEmpty(name);
|
|
return normalized === "exec" || normalized === "bash" || normalized === "shell";
|
|
}
|
|
|
|
/** Projects a completed command-tool event into the channel command-output contract. */
|
|
export function buildCommandOutputFromToolResultEvent(evt: {
|
|
stream: string;
|
|
data: Record<string, unknown>;
|
|
}): Parameters<NonNullable<GetReplyOptions["onCommandOutput"]>>[0] | undefined {
|
|
if (evt.stream !== "tool" || readStringValue(evt.data.phase) !== "result") {
|
|
return undefined;
|
|
}
|
|
const name = readStringValue(evt.data.name);
|
|
if (!name || !isCommandToolName(name)) {
|
|
return undefined;
|
|
}
|
|
const result = readRecordValue(evt.data.result);
|
|
const details = readRecordValue(result?.details);
|
|
const output =
|
|
readStringValue(evt.data.output) ??
|
|
readStringValue(result?.output) ??
|
|
readStringValue(details?.output) ??
|
|
readToolResultText(evt.data.result);
|
|
const explicitStatus =
|
|
readStringValue(evt.data.status) ??
|
|
readStringValue(result?.status) ??
|
|
readStringValue(details?.status);
|
|
const exitCode = readNullableNumberValue(
|
|
result?.exitCode ?? details?.exitCode ?? evt.data.exitCode,
|
|
);
|
|
const durationMs = readFiniteNumberValue(
|
|
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 ||
|
|
exitCode !== undefined ||
|
|
durationMs !== undefined ||
|
|
cwd !== undefined ||
|
|
(result !== undefined && Object.keys(result).length > 0);
|
|
if (!hasConcreteCommandResult) {
|
|
return 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,
|
|
toolCallId: readStringValue(evt.data.toolCallId),
|
|
name,
|
|
output,
|
|
status: explicitStatus ?? errorStatus,
|
|
exitCode,
|
|
durationMs,
|
|
cwd,
|
|
};
|
|
}
|