fix(agents): expose CLI tool summaries in terminal results (#115797)

* fix(agents): preserve CLI tool summaries

* fix(agents): cover Codex collaboration tools
This commit is contained in:
Peter Steinberger
2026-07-29 05:30:39 -04:00
committed by GitHub
parent 6dc55fa65e
commit 1f537c36c6
15 changed files with 363 additions and 3 deletions
+136
View File
@@ -14,6 +14,7 @@ import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "./embedded-agent-messaging.types.js";
import type { ToolSummaryTrace } from "./embedded-agent-runner/types.js";
export type CliUsage = {
input?: number;
@@ -51,6 +52,7 @@ export type CliOutput = {
usage?: CliUsage;
/** Terminal cumulative turn usage for diagnostics; reply accounting keeps using `usage`. */
diagnosticUsage?: CliUsage;
toolSummary?: ToolSummaryTrace;
errorText?: string;
terminalFailure?: CliTerminalFailure;
diagnostics?: {
@@ -154,6 +156,11 @@ function isGeminiCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "google-gemini-cli";
}
function isCodexExecJsonlProvider(providerId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(providerId);
return normalized === "codex" || normalized === "codex-cli";
}
function isGeminiStreamJsonDialect(params: {
backend: CliBackendConfig;
providerId: string;
@@ -1167,6 +1174,128 @@ function dispatchGeminiCliStreamingToolEvent(params: {
}
}
type CodexToolEvent = {
toolCallId: string;
name: string;
kind: CliToolUseStartDelta["kind"];
args: Record<string, unknown>;
result?: unknown;
isError: boolean;
};
function readCodexToolEvent(item: Record<string, unknown>): CodexToolEvent | null {
const toolCallId = typeof item.id === "string" ? item.id.trim() : "";
if (!toolCallId) {
return null;
}
const type = normalizeLowercaseStringOrEmpty(item.type);
if (type === "command_execution") {
return {
toolCallId,
name: "bash",
kind: "tool_use",
args: typeof item.command === "string" ? { command: item.command } : {},
result: item.aggregated_output,
isError: item.status === "failed" || item.status === "declined",
};
}
if (type === "file_change") {
return {
toolCallId,
name: "apply_patch",
kind: "tool_use",
args: Array.isArray(item.changes) ? { changes: item.changes } : {},
result: item.changes,
isError: item.status === "failed",
};
}
if (type === "web_search") {
return {
toolCallId,
name: "web_search",
kind: "server_tool_use",
args: typeof item.query === "string" ? { query: item.query } : {},
result: item.query,
isError: false,
};
}
if (type !== "mcp_tool_call") {
if (type !== "collab_tool_call") {
return null;
}
const tool = typeof item.tool === "string" ? item.tool.trim() : "";
if (!tool) {
return null;
}
return {
toolCallId,
name: `collab.${tool}`,
kind: "server_tool_use",
args: {
...(typeof item.sender_thread_id === "string"
? { sender_thread_id: item.sender_thread_id }
: {}),
...(Array.isArray(item.receiver_thread_ids)
? { receiver_thread_ids: item.receiver_thread_ids }
: {}),
...(typeof item.prompt === "string" ? { prompt: item.prompt } : {}),
},
result: item.agents_states,
isError: item.status === "failed",
};
}
const server = typeof item.server === "string" ? item.server.trim() : "";
const tool = typeof item.tool === "string" ? item.tool.trim() : "";
if (!tool) {
return null;
}
return {
toolCallId,
name: server ? `${server}.${tool}` : tool,
kind: "mcp_tool_use",
args: isRecord(item.arguments) ? item.arguments : {},
result: item.status === "failed" ? item.error : item.result,
isError: item.status === "failed",
};
}
function dispatchCodexCliStreamingToolEvent(params: {
providerId: string;
parsed: Record<string, unknown>;
tracker: ToolUseTracker;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
}): void {
if (
!isCodexExecJsonlProvider(params.providerId) ||
(params.parsed.type !== "item.started" && params.parsed.type !== "item.completed") ||
!isRecord(params.parsed.item)
) {
return;
}
const event = readCodexToolEvent(params.parsed.item);
if (!event) {
return;
}
emitToolStartOnce(
params.tracker,
event.toolCallId,
event.name,
event.kind,
event.args,
params.onToolUseStart,
);
if (params.parsed.type === "item.completed") {
emitToolResultOnce(
params.tracker,
event.toolCallId,
event.isError,
event.result,
params.onToolResult,
);
}
}
const GEMINI_CLI_ERROR_EVENT_FALLBACK = "Gemini CLI emitted an error event.";
const GEMINI_CLI_RESULT_ERROR_FALLBACK = "Gemini CLI result status was error.";
@@ -1398,6 +1527,13 @@ export function createCliJsonlStreamingParser(params: {
}
if (params.onToolUseStart || params.onToolResult) {
dispatchCodexCliStreamingToolEvent({
providerId: params.providerId,
parsed,
tracker: toolTracker,
onToolUseStart: params.onToolUseStart,
onToolResult: params.onToolResult,
});
dispatchGeminiCliStreamingToolEvent({
backend: params.backend,
providerId: params.providerId,
@@ -217,6 +217,21 @@ describe("runCliAgent before_agent_reply seam", () => {
expect(executePreparedCliRunMock).toHaveBeenCalledOnce();
});
it("projects CLI tool summaries onto terminal run metadata", async () => {
executePreparedCliRunMock.mockResolvedValue({
text: "done",
toolSummary: { calls: 1, tools: ["github.search"], failures: 0 },
});
const result = await runCliAgent(baseRunParams);
expect(result.meta.toolSummary).toEqual({
calls: 1,
tools: ["github.search"],
failures: 0,
});
});
it("preserves the send phase when execution fails before successful cleanup", async () => {
executePreparedCliRunMock.mockRejectedValueOnce(new Error("CLI process failed"));
@@ -0,0 +1,108 @@
/** Tests Codex exec JSONL tool-summary projection through the CLI process boundary. */
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import {
buildPreparedCliRunContext,
type PreparedCliRunContextOverrides,
} from "./cli-runner.test-helpers.js";
import { createManagedRun, supervisorSpawnMock } from "./cli-runner.test-support.js";
import { executePreparedCliRun } from "./cli-runner/execute.js";
const CODEX_BACKEND: PreparedCliRunContextOverrides["backend"] = {
output: "jsonl",
sessionIdFields: ["thread_id"],
systemPromptFileConfigArg: undefined,
};
function queueCodexFixture(name: string) {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 10,
stdout: readFileSync(`test/fixtures/cli/${name}.jsonl`, "utf8"),
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
}
async function runCodexFixture(name: string) {
queueCodexFixture(name);
return await executePreparedCliRun(
buildPreparedCliRunContext({
provider: "codex-cli",
model: "gpt-5.5",
backend: CODEX_BACKEND,
}),
);
}
beforeEach(() => {
supervisorSpawnMock.mockReset();
});
describe("Codex CLI tool summaries", () => {
it("emits an explicit empty summary for a successful zero-tool turn", async () => {
const output = await runCodexFixture("codex-tool-summary-zero");
expect(output.toolSummary).toEqual({ calls: 0, tools: [], failures: 0 });
});
it("counts paired MCP lifecycle events once", async () => {
const output = await runCodexFixture("codex-tool-summary-paired-mcp");
expect(output.toolSummary).toEqual({ calls: 1, tools: ["github.search"], failures: 0 });
});
it("projects terminal-only MCP and native items in first-observed order", async () => {
const output = await runCodexFixture("codex-tool-summary-terminal-only");
expect(output.toolSummary).toEqual({
calls: 4,
tools: ["lookup", "bash", "apply_patch", "web_search"],
failures: 0,
});
});
it("counts a typed failed terminal MCP item", async () => {
const output = await runCodexFixture("codex-tool-summary-failed-mcp");
expect(output.toolSummary).toEqual({ calls: 1, tools: ["docs.read"], failures: 1 });
});
it("counts a declined command terminal as a failure", async () => {
const output = await runCodexFixture("codex-tool-summary-declined-command");
expect(output.toolSummary).toEqual({ calls: 1, tools: ["bash"], failures: 1 });
});
it.each([
{
fixture: "codex-tool-summary-collab-paired",
tool: "collab.spawn_agent",
failures: 0,
},
{
fixture: "codex-tool-summary-collab-terminal-only",
tool: "collab.wait",
failures: 0,
},
{
fixture: "codex-tool-summary-collab-failed",
tool: "collab.send_input",
failures: 1,
},
{
fixture: "codex-tool-summary-collab-close-agent",
tool: "collab.close_agent",
failures: 0,
},
])("projects $fixture lifecycle metadata", async ({ fixture, tool, failures }) => {
const output = await runCodexFixture(fixture);
expect(output.toolSummary).toEqual({ calls: 1, tools: [tool], failures });
});
});
+3
View File
@@ -1209,6 +1209,9 @@ export async function runPreparedCliAgent(
stopReason,
refusal: false,
},
...(resultParams.output.toolSummary
? { toolSummary: resultParams.output.toolSummary }
: {}),
agentMeta: {
sessionId: agentSessionId,
provider: params.provider,
+46 -2
View File
@@ -7,6 +7,7 @@ import type {
CliThinkingProgress,
CliToolUseStartDelta,
} from "../cli-output.js";
import type { ToolSummaryTrace } from "../embedded-agent-runner/types.js";
import { sanitizeToolArgs, sanitizeToolResult } from "../embedded-agent-subscribe.tools.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { resolveCliToolTerminalReason } from "../run-termination.js";
@@ -20,6 +21,10 @@ type CliToolResult = {
result?: unknown;
};
function resolveCliToolSource(name: string, kind?: CliToolUseStartDelta["kind"]): "core" | "mcp" {
return kind === "mcp_tool_use" || name.startsWith("mcp__") ? "mcp" : "core";
}
export function createCliEventHandlers(params: {
context: PreparedCliRunContext;
toolTracking: CliToolTracking;
@@ -32,12 +37,49 @@ export function createCliEventHandlers(params: {
let signaledToolExecutionStarted = false;
let signaledAssistantOutputStarted = false;
let commentaryCounter = 0;
const toolSummaryById = new Map<string, { name: string; failed: boolean }>();
const toolSummaryNames: string[] = [];
const toolSummaryNameSet = new Set<string>();
const activeParsedTools = new Map<
string,
{ startedAt: number; toolName: string; kind: CliToolUseStartDelta["kind"] }
>();
const rememberToolName = (name: string) => {
if (!name || toolSummaryNameSet.has(name)) {
return;
}
toolSummaryNameSet.add(name);
toolSummaryNames.push(name);
};
const recordToolStart = (event: CliToolUseStartDelta) => {
const current = toolSummaryById.get(event.toolCallId);
if (!current) {
toolSummaryById.set(event.toolCallId, { name: event.name, failed: false });
} else if (!current.name && event.name) {
current.name = event.name;
}
rememberToolName(event.name);
};
const recordToolResult = (event: CliToolResult) => {
const current = toolSummaryById.get(event.toolCallId);
if (current) {
current.failed ||= event.isError;
if (!current.name && event.name) {
current.name = event.name;
}
} else {
toolSummaryById.set(event.toolCallId, { name: event.name, failed: event.isError });
}
rememberToolName(event.name);
};
const getToolSummary = (): ToolSummaryTrace => ({
calls: toolSummaryById.size,
tools: toolSummaryNames.slice(),
failures: Array.from(toolSummaryById.values()).filter((entry) => entry.failed).length,
});
const emitCliToolUseStart = (event: CliToolUseStartDelta) => {
observedCliActivity = true;
recordToolStart(event);
if (!signaledToolExecutionStarted) {
signaledToolExecutionStarted = true;
runParams.onExecutionPhase?.({
@@ -63,6 +105,7 @@ export function createCliEventHandlers(params: {
};
const emitCliToolResult = (event: CliToolResult) => {
observedCliActivity = true;
recordToolResult(event);
params.toolTracking.handleCliToolResult(event);
if (emitLiveEvents) {
emitAgentEvent({
@@ -92,7 +135,7 @@ export function createCliEventHandlers(params: {
...(runParams.sessionKey ? { sessionKey: runParams.sessionKey } : {}),
...(runParams.agentId ? { agentId: runParams.agentId } : {}),
toolName: event.name,
toolSource: event.name.startsWith("mcp__") ? "mcp" : "core",
toolSource: resolveCliToolSource(event.name, event.kind),
toolOwner: "cli-runner",
toolCallId: event.toolCallId,
});
@@ -136,7 +179,7 @@ export function createCliEventHandlers(params: {
...(runParams.sessionKey ? { sessionKey: runParams.sessionKey } : {}),
...(runParams.agentId ? { agentId: runParams.agentId } : {}),
toolName,
toolSource: toolName.startsWith("mcp__") ? ("mcp" as const) : ("core" as const),
toolSource: resolveCliToolSource(toolName, activeTool?.kind),
toolOwner: "cli-runner",
toolCallId: event.toolCallId,
durationMs: Math.max(0, now - (activeTool?.startedAt ?? now)),
@@ -294,6 +337,7 @@ export function createCliEventHandlers(params: {
emitCliPlanUpdate,
hasObservedCliActivity: () => observedCliActivity,
activeParsedToolCount: () => activeParsedTools.size,
getToolSummary,
};
}
+4 -1
View File
@@ -515,7 +515,10 @@ export async function executePreparedCliRun(
if (!runOutput) {
throw new Error("CLI run completed without output");
}
return toolTracking.withExecutionEvidence(runOutput);
return toolTracking.withExecutionEvidence({
...runOutput,
toolSummary: events.getToolSummary(),
});
};
try {
completedOutput = await enqueueCliRun(queueKey, async () => {
@@ -0,0 +1,6 @@
{"type":"thread.started","thread_id":"thread-collab-close"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"collab-close","type":"collab_tool_call","tool":"close_agent","sender_thread_id":"thread-parent","receiver_thread_ids":["thread-child"],"prompt":null,"agents_states":{"thread-child":{"status":"running","message":null}},"status":"in_progress"}}
{"type":"item.completed","item":{"id":"collab-close","type":"collab_tool_call","tool":"close_agent","sender_thread_id":"thread-parent","receiver_thread_ids":["thread-child"],"prompt":null,"agents_states":{"thread-child":{"status":"shutdown","message":null}},"status":"completed"}}
{"type":"item.completed","item":{"id":"message-collab-close","type":"agent_message","text":"collab closed"}}
{"type":"turn.completed","usage":{"input_tokens":4,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2,"reasoning_output_tokens":0}}
@@ -0,0 +1,5 @@
{"type":"thread.started","thread_id":"thread-collab-failed"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"collab-failed","type":"collab_tool_call","tool":"send_input","sender_thread_id":"thread-parent","receiver_thread_ids":["missing-thread"],"prompt":"continue","agents_states":{"missing-thread":{"status":"not_found","message":"agent not found"}},"status":"failed"}}
{"type":"item.completed","item":{"id":"message-collab-failed","type":"agent_message","text":"collab failed"}}
{"type":"turn.completed","usage":{"input_tokens":4,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2,"reasoning_output_tokens":0}}
@@ -0,0 +1,6 @@
{"type":"thread.started","thread_id":"thread-collab-paired"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"collab-paired","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"thread-parent","receiver_thread_ids":[],"prompt":"draft a plan","agents_states":{},"status":"in_progress"}}
{"type":"item.completed","item":{"id":"collab-paired","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"thread-parent","receiver_thread_ids":["thread-child"],"prompt":"draft a plan","agents_states":{"thread-child":{"status":"running","message":null}},"status":"completed"}}
{"type":"item.completed","item":{"id":"message-collab-paired","type":"agent_message","text":"collab paired"}}
{"type":"turn.completed","usage":{"input_tokens":5,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":3,"reasoning_output_tokens":0}}
@@ -0,0 +1,5 @@
{"type":"thread.started","thread_id":"thread-collab-terminal"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"collab-terminal","type":"collab_tool_call","tool":"wait","sender_thread_id":"thread-parent","receiver_thread_ids":["thread-child"],"prompt":null,"agents_states":{"thread-child":{"status":"completed","message":"done"}},"status":"completed"}}
{"type":"item.completed","item":{"id":"message-collab-terminal","type":"agent_message","text":"collab terminal"}}
{"type":"turn.completed","usage":{"input_tokens":4,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2,"reasoning_output_tokens":0}}
@@ -0,0 +1,6 @@
{"type":"thread.started","thread_id":"thread-declined"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"command-declined","type":"command_execution","command":"rm protected.txt","aggregated_output":"","status":"in_progress"}}
{"type":"item.completed","item":{"id":"command-declined","type":"command_execution","command":"rm protected.txt","aggregated_output":"","status":"declined"}}
{"type":"item.completed","item":{"id":"message-declined","type":"agent_message","text":"command declined"}}
{"type":"turn.completed","usage":{"input_tokens":4,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2,"reasoning_output_tokens":0}}
+5
View File
@@ -0,0 +1,5 @@
{"type":"thread.started","thread_id":"thread-failed"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"mcp-failed","type":"mcp_tool_call","server":"docs","tool":"read","arguments":{"path":"missing"},"error":{"message":"not found"},"status":"failed"}}
{"type":"item.completed","item":{"id":"message-failed","type":"agent_message","text":"handled failure"}}
{"type":"turn.completed","usage":{"input_tokens":6,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":3,"reasoning_output_tokens":0}}
+6
View File
@@ -0,0 +1,6 @@
{"type":"thread.started","thread_id":"thread-paired"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"mcp-paired","type":"mcp_tool_call","server":"github","tool":"search","arguments":{"query":"openclaw"},"status":"in_progress"}}
{"type":"item.completed","item":{"id":"mcp-paired","type":"mcp_tool_call","server":"github","tool":"search","arguments":{"query":"openclaw"},"result":{"content":[],"structured_content":{}},"status":"completed"}}
{"type":"item.completed","item":{"id":"message-paired","type":"agent_message","text":"paired mcp"}}
{"type":"turn.completed","usage":{"input_tokens":5,"cached_input_tokens":1,"cache_write_input_tokens":0,"output_tokens":3,"reasoning_output_tokens":0}}
@@ -0,0 +1,8 @@
{"type":"thread.started","thread_id":"thread-terminal-only"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"mcp-terminal","type":"mcp_tool_call","server":"","tool":"lookup","arguments":{"id":"42"},"result":{"content":[],"structured_content":{}},"status":"completed"}}
{"type":"item.completed","item":{"id":"command-terminal","type":"command_execution","command":"pwd","aggregated_output":"/workspace\n","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"patch-terminal","type":"file_change","changes":[{"path":"README.md","kind":"update"}],"status":"completed"}}
{"type":"item.completed","item":{"id":"search-terminal","type":"web_search","query":"OpenClaw"}}
{"type":"item.completed","item":{"id":"message-terminal","type":"agent_message","text":"terminal only"}}
{"type":"turn.completed","usage":{"input_tokens":8,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":4,"reasoning_output_tokens":1}}
+4
View File
@@ -0,0 +1,4 @@
{"type":"thread.started","thread_id":"thread-zero"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"message-zero","type":"agent_message","text":"zero tools"}}
{"type":"turn.completed","usage":{"input_tokens":3,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2,"reasoning_output_tokens":0}}