mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-16 10:28:45 +00:00
fix(codex): observe native tool completions
This commit is contained in:
committed by
Peter Steinberger
parent
5b6f4d6bb6
commit
a28bf10ce2
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user