From 925d11d89054c5dd1ba31de5537d2ee3cb5cdd3b Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 23 Apr 2026 23:42:09 -0700 Subject: [PATCH] fix: match codex verbose tool logs to pi (#70966) --- CHANGELOG.md | 1 + docs/plugins/sdk-subpaths.md | 2 +- .../src/app-server/event-projector.test.ts | 97 ++++++++++- .../codex/src/app-server/event-projector.ts | 157 +++++++++++++++++- src/plugin-sdk/agent-harness-runtime.ts | 11 ++ 5 files changed, 264 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9edf1022a..9c4607736ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload. +- Codex harness: send verbose tool progress to chat channels for native app-server runs, matching the Pi harness `/verbose on` and `/verbose full` behavior. - Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions. - Codex status: report Codex CLI OAuth as `oauth (codex-cli)` for native `codex/*` sessions instead of showing unknown auth. Fixes #70688. Thanks @jb510. - Codex harness/context-engine: redact context-engine assembly failures before logging, so fallback warnings do not serialize raw error objects. (#70809) Thanks @jalehman. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 51d86db060d..a52cc263d70 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -189,7 +189,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/models-provider-runtime` | `/models` command/provider reply helpers | | `plugin-sdk/skill-commands-runtime` | Skill command listing helpers | | `plugin-sdk/native-command-registry` | Native command registry/build/serialize helpers | - | `plugin-sdk/agent-harness` | Experimental trusted-plugin surface for low-level agent harnesses: harness types, active-run steer/abort helpers, OpenClaw tool bridge helpers, and attempt result utilities | + | `plugin-sdk/agent-harness` | Experimental trusted-plugin surface for low-level agent harnesses: harness types, active-run steer/abort helpers, OpenClaw tool bridge helpers, tool progress formatting/detail helpers, and attempt result utilities | | `plugin-sdk/provider-zai-endpoint` | Z.AI endpoint detection helpers | | `plugin-sdk/infra-runtime` | System event/heartbeat helpers | | `plugin-sdk/collection-runtime` | Small bounded cache helpers | diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 3ac9b2854eb..e0a1ad050b9 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -490,7 +490,7 @@ describe("CodexAppServerEventProjector", () => { data: expect.objectContaining({ phase: "start", itemId: "compact-1" }), }), ); - expect(result.toolMetas).toEqual([{ toolName: "sessions_send", meta: "completed" }]); + expect(result.toolMetas).toEqual([{ toolName: "sessions_send" }]); expect(result.messagesSnapshot.map((message) => message.role)).toEqual([ "user", "assistant", @@ -501,6 +501,101 @@ describe("CodexAppServerEventProjector", () => { expect(result.itemLifecycle).toMatchObject({ compactionCount: 1 }); }); + it("emits verbose tool summaries through onToolResult", async () => { + const onToolResult = vi.fn(); + const projector = await createProjector({ + ...(await createParams()), + verboseLevel: "on", + onToolResult, + }); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { + type: "commandExecution", + id: "cmd-1", + command: "pnpm test extensions/codex", + cwd: "/workspace", + processId: null, + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }), + ); + + expect(onToolResult).toHaveBeenCalledTimes(1); + expect(onToolResult).toHaveBeenCalledWith({ + text: "🛠️ Bash: `pnpm test extensions/codex`", + }); + }); + + it("uses argument details instead of lifecycle status in verbose tool summaries", async () => { + const onToolResult = vi.fn(); + const projector = await createProjector({ + ...(await createParams()), + verboseLevel: "on", + onToolResult, + }); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { + type: "dynamicToolCall", + id: "tool-1", + namespace: null, + tool: "lcm_grep", + arguments: { query: "inProgress text" }, + status: "inProgress", + contentItems: null, + success: null, + durationMs: null, + }, + }), + ); + + expect(onToolResult).toHaveBeenCalledTimes(1); + expect(onToolResult).toHaveBeenCalledWith({ + text: "🧩 Lcm Grep: `inProgress text`", + }); + }); + + it("emits completed tool output only when verbose full is enabled", async () => { + const onToolResult = vi.fn(); + const projector = await createProjector({ + ...(await createParams()), + verboseLevel: "full", + onToolResult, + }); + + await projector.handleNotification( + turnCompleted([ + { + type: "dynamicToolCall", + id: "tool-1", + namespace: null, + tool: "read", + arguments: { path: "README.md" }, + status: "completed", + contentItems: [{ type: "inputText", text: "file contents" }], + success: true, + durationMs: 12, + }, + ]), + ); + + expect(onToolResult).toHaveBeenCalledTimes(2); + expect(onToolResult).toHaveBeenNthCalledWith(1, { + text: "📖 Read: `from README.md`", + }); + expect(onToolResult).toHaveBeenNthCalledWith(2, { + text: "📖 Read: `from README.md`\n```txt\nfile contents\n```", + }); + }); + it("continues projecting turn completion when an event consumer throws", async () => { const onAgentEvent = vi.fn(() => { throw new Error("consumer failed"); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 8dfd57631bf..126e5b43594 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -3,13 +3,16 @@ import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { formatErrorMessage, + inferToolMetaFromArgs, normalizeUsage, runAgentHarnessAfterCompactionHook, runAgentHarnessBeforeCompactionHook, type EmbeddedRunAttemptParams, type EmbeddedRunAttemptResult, + formatToolAggregate, type MessagingToolSend, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readCodexTurn } from "./protocol-validators.js"; import { isJsonObject, type CodexServerNotification, @@ -18,7 +21,6 @@ import { type JsonObject, type JsonValue, } from "./protocol.js"; -import { readCodexTurn } from "./protocol-validators.js"; export type CodexAppServerToolTelemetry = { didSendViaMessagingTool: boolean; @@ -62,6 +64,8 @@ export class CodexAppServerEventProjector { private readonly activeItemIds = new Set(); private readonly completedItemIds = new Set(); private readonly activeCompactionItemIds = new Set(); + private readonly toolResultSummaryItemIds = new Set(); + private readonly toolResultOutputItemIds = new Set(); private readonly toolMetas = new Map(); private assistantStarted = false; private reasoningStarted = false; @@ -106,6 +110,12 @@ export class CodexAppServerEventProjector { case "item/completed": await this.handleItemCompleted(params); break; + case "item/commandExecution/outputDelta": + this.handleOutputDelta(params, "bash"); + break; + case "item/fileChange/outputDelta": + this.handleOutputDelta(params, "apply_patch"); + break; case "item/autoApprovalReview/started": case "item/autoApprovalReview/completed": this.handleGuardianReviewNotification(notification.method, params); @@ -307,6 +317,7 @@ export class CodexAppServerEventProjector { }); } this.emitStandardItemEvent({ phase: "start", item }); + this.emitToolResultSummary(item); this.emitAgentEvent({ stream: "codex_app_server.item", data: { phase: "started", itemId, type: item?.type }, @@ -359,6 +370,8 @@ export class CodexAppServerEventProjector { } this.recordToolMeta(item); this.emitStandardItemEvent({ phase: "end", item }); + this.emitToolResultSummary(item); + this.emitToolResultOutput(item); this.emitAgentEvent({ stream: "codex_app_server.item", data: { phase: "completed", itemId, type: item?.type }, @@ -423,11 +436,26 @@ export class CodexAppServerEventProjector { this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) }); } this.recordToolMeta(item); + this.emitToolResultSummary(item); + this.emitToolResultOutput(item); } this.activeCompactionItemIds.clear(); await this.maybeEndReasoning(); } + private handleOutputDelta(params: JsonObject, toolName: string): void { + const itemId = readString(params, "itemId"); + const delta = readString(params, "delta"); + if (!itemId || !delta || !this.shouldEmitToolOutput()) { + return; + } + this.emitToolResultMessage({ + itemId, + text: formatToolOutput(toolName, undefined, delta), + output: true, + }); + } + private handleRawResponseItemCompleted(params: JsonObject): void { const item = isJsonObject(params.item) ? params.item : undefined; if (!item || readString(item, "role") !== "assistant") { @@ -492,6 +520,71 @@ export class CodexAppServerEventProjector { }); } + private emitToolResultSummary(item: CodexThreadItem | undefined): void { + if (!item || !this.params.onToolResult || !this.shouldEmitToolResult()) { + return; + } + const itemId = item.id; + if (this.toolResultSummaryItemIds.has(itemId)) { + return; + } + const toolName = itemName(item); + if (!toolName) { + return; + } + this.toolResultSummaryItemIds.add(itemId); + const meta = itemMeta(item); + this.emitToolResultMessage({ + itemId, + text: formatToolSummary(toolName, meta), + }); + } + + private emitToolResultOutput(item: CodexThreadItem | undefined): void { + if (!item || !this.params.onToolResult || !this.shouldEmitToolOutput()) { + return; + } + const itemId = item.id; + if (this.toolResultOutputItemIds.has(itemId)) { + return; + } + const toolName = itemName(item); + const output = itemOutputText(item); + if (!toolName || !output) { + return; + } + this.emitToolResultMessage({ + itemId, + text: formatToolOutput(toolName, itemMeta(item), output), + output: true, + }); + } + + private emitToolResultMessage(params: { itemId: string; text: string; output?: boolean }): void { + if (params.output) { + this.toolResultOutputItemIds.add(params.itemId); + } + try { + void Promise.resolve(this.params.onToolResult?.({ text: params.text })).catch(() => { + // Tool progress delivery is best-effort and should not affect the turn. + }); + } catch { + // Tool progress delivery is best-effort and should not affect the turn. + } + } + + private shouldEmitToolResult(): boolean { + return typeof this.params.shouldEmitToolResult === "function" + ? this.params.shouldEmitToolResult() + : this.params.verboseLevel === "on" || this.params.verboseLevel === "full"; + } + + private shouldEmitToolOutput(): boolean { + return typeof this.params.shouldEmitToolOutput === "function" + ? this.params.shouldEmitToolOutput() + : this.params.verboseLevel === "full"; + } + private recordToolMeta(item: CodexThreadItem | undefined): void { if (!item) { return; @@ -777,7 +870,67 @@ function itemMeta(item: CodexThreadItem): string | undefined { if (item.type === "webSearch" && typeof item.query === "string") { return item.query; } - return readItemString(item, "status"); + const toolName = itemName(item); + if ((item.type === "dynamicToolCall" || item.type === "mcpToolCall") && toolName) { + return inferToolMetaFromArgs(toolName, item.arguments); + } + return undefined; +} + +function itemOutputText(item: CodexThreadItem): string | undefined { + if (item.type === "commandExecution") { + return item.aggregatedOutput?.trim() || undefined; + } + if (item.type === "dynamicToolCall") { + return collectDynamicToolContentText(item.contentItems).trim() || undefined; + } + if (item.type === "mcpToolCall") { + if (item.error) { + return stringifyJsonValue(item.error); + } + return item.result ? stringifyJsonValue(item.result) : undefined; + } + return undefined; +} + +function collectDynamicToolContentText( + contentItems: Extract["contentItems"], +): string { + if (!Array.isArray(contentItems)) { + return ""; + } + return contentItems + .flatMap((entry) => { + if (!isJsonObject(entry)) { + return []; + } + const text = readString(entry, "text"); + return text ? [text] : []; + }) + .join("\n"); +} + +function stringifyJsonValue(value: unknown): string | undefined { + try { + return JSON.stringify(value, null, 2); + } catch { + return undefined; + } +} + +function formatToolSummary(toolName: string, meta?: string): string { + const trimmedMeta = meta?.trim(); + return formatToolAggregate(toolName, trimmedMeta ? [trimmedMeta] : undefined, { + markdown: true, + }); +} + +function formatToolOutput(toolName: string, meta: string | undefined, output: string): string { + const trimmed = output.trim(); + if (!trimmed) { + return formatToolSummary(toolName, meta); + } + return `${formatToolSummary(toolName, meta)}\n\`\`\`txt\n${trimmed}\n\`\`\``; } function readItemString(item: CodexThreadItem, key: string): string | undefined { diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index bb220638752..2c033478a98 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -2,6 +2,8 @@ // Keep heavyweight tool construction out of this module so harness imports can // register quickly inside gateway startup and Docker e2e runs. +import { formatToolDetail, resolveToolDisplay } from "../agents/tool-display.js"; + export type { AgentHarness, AgentHarnessAttemptParams, @@ -37,6 +39,7 @@ export { log as embeddedAgentLog } from "../agents/pi-embedded-runner/logger.js" export { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; export { resolveUserPath } from "../utils.js"; export { callGatewayTool } from "../agents/tools/gateway.js"; +export { formatToolAggregate } from "../auto-reply/tool-meta.js"; export { isMessagingTool, isMessagingToolSendAction } from "../agents/pi-embedded-messaging.js"; export { extractToolResultMediaArtifact, @@ -85,3 +88,11 @@ export { runAgentHarnessLlmInputHook, runAgentHarnessLlmOutputHook, } from "../agents/harness/lifecycle-hook-helpers.js"; + +/** + * Derive the same compact user-facing tool detail that Pi uses for progress logs. + */ +export function inferToolMetaFromArgs(toolName: string, args: unknown): string | undefined { + const display = resolveToolDisplay({ name: toolName, args }); + return formatToolDetail(display); +}