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:
pashpashpash
2026-04-23 23:43:03 -07:00
committed by GitHub
parent 925d11d890
commit 41c5ffc5d5
3 changed files with 175 additions and 1 deletions

View File

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

View File

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