diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 4ee76b02061..810bd650911 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -80,6 +80,11 @@ OpenClaw can mirror selected events, but it cannot rewrite the native Codex thread unless Codex exposes that operation through app-server or native hook callbacks. +Codex app-server item notifications also provide async `after_tool_call` +observations for native tool completions that are not already covered by the +native `PostToolUse` relay. These observations are for telemetry and plugin +compatibility only; they cannot block, delay, or mutate the native tool call. + Compaction and LLM lifecycle projections come from Codex app-server notifications and OpenClaw adapter state, not native Codex hook commands. OpenClaw's `before_compaction`, `after_compaction`, `llm_input`, and diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 7d6708e5b10..069eb690eac 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -17,6 +17,7 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CodexAppServerEventProjector, + type CodexAppServerEventProjectorOptions, type CodexAppServerToolTelemetry, } from "./event-projector.js"; import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js"; @@ -72,9 +73,10 @@ async function createParams(): Promise { async function createProjector( params?: EmbeddedRunAttemptParams, + options?: CodexAppServerEventProjectorOptions, ): Promise { const resolvedParams = params ?? (await createParams()); - return new CodexAppServerEventProjector(resolvedParams, THREAD_ID, TURN_ID); + return new CodexAppServerEventProjector(resolvedParams, THREAD_ID, TURN_ID, options); } async function createProjectorWithAssistantHooks() { @@ -1034,6 +1036,134 @@ describe("CodexAppServerEventProjector", () => { ]); }); + it("emits after_tool_call observations for Codex-native tool item completions", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + const projector = await createProjector({ + ...(await createParams()), + agentId: "main", + sessionKey: "agent:main:session-1", + }); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { + type: "commandExecution", + id: "cmd-observed", + command: "pnpm test extensions/codex", + cwd: "/workspace", + processId: null, + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }), + ); + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "commandExecution", + id: "cmd-observed", + command: "pnpm test extensions/codex", + cwd: "/workspace", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "ok", + exitCode: 0, + durationMs: 42, + }, + }), + ); + + await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1)); + const event = requireRecord( + mockCallArg(afterToolCall, 0, 0, "after_tool_call event"), + "after_tool_call event", + ); + expect(event).toMatchObject({ + toolName: "bash", + params: { command: "pnpm test extensions/codex", cwd: "/workspace" }, + runId: "run-1", + toolCallId: "cmd-observed", + result: { status: "completed", exitCode: 0, durationMs: 42 }, + }); + expect(event.durationMs).toBeGreaterThanOrEqual(42); + const context = requireRecord( + mockCallArg(afterToolCall, 0, 1, "after_tool_call context"), + "after_tool_call context", + ); + expect(context).toMatchObject({ + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + toolName: "bash", + toolCallId: "cmd-observed", + }); + }); + + it("does not duplicate native items already covered by PostToolUse relay", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + const projector = await createProjector( + { ...(await createParams()), sessionKey: "agent:main:session-1" }, + { nativePostToolUseRelayEnabled: true }, + ); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "commandExecution", + id: "cmd-relayed", + command: "pnpm test extensions/codex", + cwd: "/workspace", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "ok", + exitCode: 0, + durationMs: 42, + }, + }), + ); + expect(afterToolCall).not.toHaveBeenCalled(); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "webSearch", + id: "search-observed", + query: "opik openclaw codex", + status: "completed", + durationMs: 5, + }, + }), + ); + + await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1)); + const event = requireRecord( + mockCallArg(afterToolCall, 0, 0, "after_tool_call event"), + "after_tool_call event", + ); + expect(event).toMatchObject({ + toolName: "web_search", + params: { query: "opik openclaw codex" }, + runId: "run-1", + toolCallId: "search-observed", + result: { status: "completed" }, + }); + }); + it("records dynamic OpenClaw tool calls in mirrored transcript snapshots", async () => { const projector = await createProjector(); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index e83bdc3255e..6ba10ca888d 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -9,6 +9,7 @@ import { inferToolMetaFromArgs, normalizeUsage, runAgentHarnessAfterCompactionHook, + runAgentHarnessAfterToolCallHook, runAgentHarnessBeforeCompactionHook, TOOL_PROGRESS_OUTPUT_MAX_CHARS, type AgentMessage, @@ -50,6 +51,10 @@ export type CodexAppServerToolTelemetry = { successfulCronAdds?: number; }; +export type CodexAppServerEventProjectorOptions = { + nativePostToolUseRelayEnabled?: boolean; +}; + const ZERO_USAGE: Usage = { input: 0, output: 0, @@ -118,6 +123,7 @@ export class CodexAppServerEventProjector { private readonly toolTranscriptResultIds = new Set(); private readonly nativeGeneratedMediaUrls = new Set(); private readonly diagnosticToolStartedAtByItem = new Map(); + private readonly afterToolCallObservedItemIds = new Set(); private assistantStarted = false; private reasoningStarted = false; private reasoningEnded = false; @@ -134,6 +140,7 @@ export class CodexAppServerEventProjector { private readonly params: EmbeddedRunAttemptParams, private readonly threadId: string, private readonly turnId: string, + private readonly options: CodexAppServerEventProjectorOptions = {}, ) {} async handleNotification(notification: CodexServerNotification): Promise { @@ -613,6 +620,7 @@ export class CodexAppServerEventProjector { this.recordToolMeta(item); this.recordNativeToolTranscriptCall(item); this.recordNativeToolTranscriptResult(item); + this.emitAfterToolCallObservation(item); this.emitToolResultSummary(item); this.emitToolResultOutput(item); } @@ -790,6 +798,9 @@ export class CodexAppServerEventProjector { : {}), }, }); + if (params.phase === "result") { + this.emitAfterToolCallObservation(item); + } } private emitDiagnosticToolExecutionEvent(params: { @@ -840,6 +851,53 @@ export class CodexAppServerEventProjector { emitTrustedDiagnosticEvent({ ...base, ...terminalEvent }); } + private emitAfterToolCallObservation(item: CodexThreadItem): void { + if (!this.shouldEmitAfterToolCallObservation(item)) { + return; + } + const name = itemName(item); + if (!name) { + return; + } + const status = itemStatus(item); + if (status === "running") { + return; + } + this.afterToolCallObservedItemIds.add(item.id); + const result = itemToolResult(item).result; + const error = itemToolError(item, status); + const startedAt = + typeof item.durationMs === "number" ? Date.now() - Math.max(0, item.durationMs) : undefined; + const hookParams = { + toolName: name, + toolCallId: item.id, + runId: this.params.runId, + agentId: this.params.agentId, + sessionId: this.params.sessionId, + sessionKey: this.params.sessionKey, + startArgs: itemToolArgs(item) ?? {}, + ...(result !== undefined ? { result } : {}), + ...(error ? { error } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + }; + setImmediate(() => { + void runAgentHarnessAfterToolCallHook(hookParams); + }); + } + + private shouldEmitAfterToolCallObservation(item: CodexThreadItem): boolean { + if ( + !shouldSynthesizeToolProgressForItem(item) || + this.afterToolCallObservedItemIds.has(item.id) + ) { + return false; + } + if (this.options.nativePostToolUseRelayEnabled && isNativePostToolUseRelayItem(item)) { + return false; + } + return true; + } + private emitToolResultSummary(item: CodexThreadItem | undefined): void { if (!item || !this.params.onToolResult || !this.shouldEmitToolResult()) { return; @@ -1397,6 +1455,17 @@ function shouldSynthesizeToolProgressForItem(item: CodexThreadItem): boolean { } } +function isNativePostToolUseRelayItem(item: CodexThreadItem): boolean { + switch (item.type) { + case "commandExecution": + case "fileChange": + case "mcpToolCall": + return true; + default: + return false; + } +} + function shouldSuppressChannelProgressForItem(item: CodexThreadItem): boolean { if (shouldSynthesizeToolProgressForItem(item)) { return true; @@ -1414,6 +1483,11 @@ function itemToolArgs(item: CodexThreadItem): Record | undefine ...(typeof item.cwd === "string" ? { cwd: item.cwd } : {}), }); } + if (item.type === "fileChange") { + return sanitizeCodexAgentEventRecord({ + changes: itemFileChanges(item), + }); + } if (item.type === "webSearch" && typeof item.query === "string") { return sanitizeCodexAgentEventRecord({ query: item.query }); } @@ -1437,7 +1511,7 @@ function itemToolResult(item: CodexThreadItem): { result?: Record ({ path: change.path, kind: change.kind })), + changes: itemFileChanges(item), }), }; } @@ -1457,6 +1531,25 @@ function itemToolResult(item: CodexThreadItem): { result?: Record { + return Array.isArray(item.changes) + ? item.changes.map((change) => ({ path: change.path, kind: change.kind })) + : []; +} + +function itemToolError( + item: CodexThreadItem, + status: ReturnType, +): string | undefined { + if (status === "blocked") { + return "codex native tool blocked"; + } + if (status !== "failed") { + return undefined; + } + return itemOutputText(item) ?? "codex native tool failed"; +} + function itemMeta( item: CodexThreadItem, detailMode: ToolProgressDetailMode = "explain", diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index a18b8753e85..4d9ec66a736 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1448,7 +1448,10 @@ export async function runCodexAppServerAttempt( prompt: promptBuild.prompt, imagesCount: params.images?.length ?? 0, }); - projector = new CodexAppServerEventProjector(params, thread.threadId, activeTurnId); + projector = new CodexAppServerEventProjector(params, thread.threadId, activeTurnId, { + nativePostToolUseRelayEnabled: + nativeHookRelay?.allowedEvents.includes("post_tool_use") === true, + }); emitLifecycleStart(); const activeProjector = projector; for (const notification of pendingNotifications.splice(0)) {