mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
Project Codex hook notifications into agent events (#70969)
* project codex hook notifications * keep codex hook duration strict * include thread scoped codex hook notifications
This commit is contained in:
@@ -536,6 +536,10 @@ Until then, OpenClaw's `before_compaction`, `after_compaction`, `llm_input`, and
|
||||
`llm_output` events are adapter-level observations, not byte-for-byte captures
|
||||
of Codex's internal request or compaction payloads.
|
||||
|
||||
Codex native `hook/started` and `hook/completed` app-server notifications are
|
||||
projected as `codex_app_server.hook` agent events for trajectory and debugging.
|
||||
They do not invoke OpenClaw plugin hooks.
|
||||
|
||||
## Tools, media, and compaction
|
||||
|
||||
The Codex harness changes the low-level embedded agent executor only.
|
||||
|
||||
@@ -663,4 +663,105 @@ describe("CodexAppServerEventProjector", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("projects codex hook started and completed notifications into agent events", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({ ...params, onAgentEvent });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("hook/started", {
|
||||
run: {
|
||||
id: "hook-1",
|
||||
eventName: "preToolUse",
|
||||
handlerType: "command",
|
||||
executionMode: "sync",
|
||||
scope: "turn",
|
||||
source: "project",
|
||||
sourcePath: "/repo/.codex/hooks.json",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
entries: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("hook/completed", {
|
||||
run: {
|
||||
id: "hook-1",
|
||||
eventName: "preToolUse",
|
||||
handlerType: "command",
|
||||
executionMode: "sync",
|
||||
scope: "turn",
|
||||
source: "project",
|
||||
sourcePath: "/repo/.codex/hooks.json",
|
||||
status: "blocked",
|
||||
statusMessage: "blocked by hook",
|
||||
durationMs: 42,
|
||||
entries: [{ kind: "stderr", text: "blocked" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "codex_app_server.hook",
|
||||
data: expect.objectContaining({
|
||||
phase: "started",
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
hookRunId: "hook-1",
|
||||
eventName: "preToolUse",
|
||||
status: "running",
|
||||
}),
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "codex_app_server.hook",
|
||||
data: expect.objectContaining({
|
||||
phase: "completed",
|
||||
hookRunId: "hook-1",
|
||||
status: "blocked",
|
||||
statusMessage: "blocked by hook",
|
||||
durationMs: 42,
|
||||
entries: [{ kind: "stderr", text: "blocked" }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("projects thread-scoped codex hook notifications that omit a turn id", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({ ...params, onAgentEvent });
|
||||
|
||||
await projector.handleNotification({
|
||||
method: "hook/started",
|
||||
params: {
|
||||
threadId: THREAD_ID,
|
||||
turnId: null,
|
||||
run: {
|
||||
id: "hook-thread-1",
|
||||
eventName: "sessionStart",
|
||||
handlerType: "command",
|
||||
executionMode: "sync",
|
||||
scope: "thread",
|
||||
source: "project",
|
||||
sourcePath: "/repo/.codex/hooks.json",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "codex_app_server.hook",
|
||||
data: expect.objectContaining({
|
||||
phase: "started",
|
||||
threadId: THREAD_ID,
|
||||
turnId: null,
|
||||
hookRunId: "hook-thread-1",
|
||||
eventName: "sessionStart",
|
||||
scope: "thread",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,14 @@ export class CodexAppServerEventProjector {
|
||||
|
||||
async handleNotification(notification: CodexServerNotification): Promise<void> {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || !this.isNotificationForTurn(params)) {
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
if (isHookNotificationMethod(notification.method)) {
|
||||
if (!this.isHookNotificationForCurrentThread(params)) {
|
||||
return;
|
||||
}
|
||||
} else if (!this.isNotificationForTurn(params)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,6 +127,10 @@ export class CodexAppServerEventProjector {
|
||||
case "item/autoApprovalReview/completed":
|
||||
this.handleGuardianReviewNotification(notification.method, params);
|
||||
break;
|
||||
case "hook/started":
|
||||
case "hook/completed":
|
||||
this.handleHookNotification(notification.method, params);
|
||||
break;
|
||||
case "thread/tokenUsage/updated":
|
||||
this.handleTokenUsage(params);
|
||||
break;
|
||||
@@ -413,6 +424,35 @@ export class CodexAppServerEventProjector {
|
||||
});
|
||||
}
|
||||
|
||||
private handleHookNotification(method: string, params: JsonObject): void {
|
||||
const run = isJsonObject(params.run) ? params.run : undefined;
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
const durationMs = readNumber(run, "durationMs");
|
||||
const entries = readHookOutputEntries(run.entries);
|
||||
const hookTurnId = readNullableString(params, "turnId");
|
||||
this.emitAgentEvent({
|
||||
stream: "codex_app_server.hook",
|
||||
data: {
|
||||
phase: method === "hook/started" ? "started" : "completed",
|
||||
threadId: this.threadId,
|
||||
turnId: hookTurnId === undefined ? this.turnId : hookTurnId,
|
||||
hookRunId: readString(run, "id"),
|
||||
eventName: readString(run, "eventName"),
|
||||
handlerType: readString(run, "handlerType"),
|
||||
executionMode: readString(run, "executionMode"),
|
||||
scope: readString(run, "scope"),
|
||||
source: readString(run, "source"),
|
||||
sourcePath: readString(run, "sourcePath"),
|
||||
status: readString(run, "status"),
|
||||
statusMessage: readNullableString(run, "statusMessage"),
|
||||
...(durationMs !== undefined ? { durationMs } : {}),
|
||||
...(entries.length > 0 ? { entries } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTurnCompleted(params: JsonObject): Promise<void> {
|
||||
const turn = readTurn(params.turn);
|
||||
if (!turn || turn.id !== this.turnId) {
|
||||
@@ -690,6 +730,16 @@ export class CodexAppServerEventProjector {
|
||||
const turnId = readNotificationTurnId(params);
|
||||
return threadId === this.threadId && turnId === this.turnId;
|
||||
}
|
||||
|
||||
private isHookNotificationForCurrentThread(params: JsonObject): boolean {
|
||||
const threadId = readString(params, "threadId");
|
||||
const turnId = params.turnId;
|
||||
return threadId === this.threadId && (turnId === this.turnId || turnId === null);
|
||||
}
|
||||
}
|
||||
|
||||
function isHookNotificationMethod(method: string): method is "hook/started" | "hook/completed" {
|
||||
return method === "hook/started" || method === "hook/completed";
|
||||
}
|
||||
|
||||
function readNotificationTurnId(record: JsonObject): string | undefined {
|
||||
@@ -719,6 +769,25 @@ function readNumber(record: JsonObject, key: string): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readHookOutputEntries(
|
||||
value: JsonValue | undefined,
|
||||
): Array<{ kind?: string; text: string }> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((entry) => {
|
||||
if (!isJsonObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
const text = readString(entry, "text");
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const kind = readString(entry, "kind");
|
||||
return [{ ...(kind ? { kind } : {}), text }];
|
||||
});
|
||||
}
|
||||
|
||||
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
|
||||
Reference in New Issue
Block a user