fix(codex): observe native tool completions

This commit is contained in:
VACInc
2026-05-10 13:31:36 -04:00
committed by Peter Steinberger
parent 5b6f4d6bb6
commit a28bf10ce2
4 changed files with 234 additions and 3 deletions

View File

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

View File

@@ -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<EmbeddedRunAttemptParams> {
async function createProjector(
params?: EmbeddedRunAttemptParams,
options?: CodexAppServerEventProjectorOptions,
): Promise<CodexAppServerEventProjector> {
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();

View File

@@ -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<string>();
private readonly nativeGeneratedMediaUrls = new Set<string>();
private readonly diagnosticToolStartedAtByItem = new Map<string, number>();
private readonly afterToolCallObservedItemIds = new Set<string>();
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<void> {
@@ -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<string, unknown> | 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<string, unknow
return {
result: sanitizeCodexAgentEventRecord({
status: item.status,
changes: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
changes: itemFileChanges(item),
}),
};
}
@@ -1457,6 +1531,25 @@ function itemToolResult(item: CodexThreadItem): { result?: Record<string, unknow
return {};
}
function itemFileChanges(item: CodexThreadItem): Array<{ path: string; kind: string }> {
return Array.isArray(item.changes)
? item.changes.map((change) => ({ path: change.path, kind: change.kind }))
: [];
}
function itemToolError(
item: CodexThreadItem,
status: ReturnType<typeof itemStatus>,
): 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",

View File

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