fix: match codex verbose tool logs to pi (#70966)

This commit is contained in:
Josh Lehman
2026-04-23 23:42:09 -07:00
committed by GitHub
parent 8fade9df27
commit 925d11d890
5 changed files with 264 additions and 4 deletions

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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");

View File

@@ -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<string>();
private readonly completedItemIds = new Set<string>();
private readonly activeCompactionItemIds = new Set<string>();
private readonly toolResultSummaryItemIds = new Set<string>();
private readonly toolResultOutputItemIds = new Set<string>();
private readonly toolMetas = new Map<string, { toolName: string; meta?: string }>();
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<CodexThreadItem, { type: "dynamicToolCall" }>["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 {

View File

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