File size: 4,005 Bytes
4440aec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import { asFiniteNumber as readFiniteNumberValue } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecordValue } from "@openclaw/normalization-core/record-coerce";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import type { EmbeddedAgentEvent } from "../../agents/embedded-agent-subscribe.shared-types.js";
import { inferToolMetaFromArgsCore } from "../../agents/tool-display.js";
import type { GetReplyOptions } from "../types.js";

/**
 * 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 readNullableNumberValue(value: unknown): number | null | undefined {
  if (value === null) {
    return null;
  }
  return readFiniteNumberValue(value);
}

function isCommandToolName(name: string | undefined): boolean {
  const normalized = name?.trim().toLowerCase();
  return normalized === "exec" || normalized === "bash" || normalized === "shell";
}

/** Projects a completed command-tool event into the channel command-output contract. */
export function buildCommandOutputFromToolResultEvent(
  evt: EmbeddedAgentEvent,
): Parameters<NonNullable<GetReplyOptions["onCommandOutput"]>>[0] | undefined {
  if (evt.stream !== "tool" || evt.data.phase !== "result") {
    return undefined;
  }
  const name = evt.data.name;
  const commandBearing = evt.data.commandBearing === true;
  if (!name || (!commandBearing && !isCommandToolName(name))) {
    return undefined;
  }
  const result = readRecordValue(evt.data.result);
  const details = readRecordValue(result?.details);
  const output =
    evt.data.output ??
    readStringValue(result?.output) ??
    readStringValue(details?.output) ??
    readToolResultText(evt.data.result);
  const explicitStatus =
    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 = 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 ||
    (commandBearing && typeof evt.data.isError === "boolean") ||
    (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 = evt.data.args;
  const title =
    evt.data.title ??
    (args ? inferToolMetaFromArgsCore(name, args, { detailMode: "explain" }) : undefined);
  return {
    itemId: evt.data.itemId,
    phase: "end",
    title,
    toolCallId: evt.data.toolCallId,
    name,
    output,
    status: explicitStatus ?? errorStatus,
    exitCode,
    durationMs,
    cwd,
  };
}