























@@ -10,12 +10,14 @@ import {
1010createParams,
1111createResumeHarness,
1212createStartedThreadHarness,
13+extractGenerationFromThreadRequest,
1314extractRelayIdFromThreadRequest,
1415runCodexAppServerAttempt,
1516setupRunAttemptTestHooks,
1617tempDir,
1718} from "./run-attempt-test-harness.js";
1819import { testing } from "./run-attempt.js";
20+import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
19212022setupRunAttemptTestHooks();
2123@@ -440,6 +442,202 @@ describe("runCodexAppServerAttempt native hook relay", () => {
440442).toBeUndefined();
441443});
442444445+it("persists and reuses Codex native hook relay generations for resumed threads", async () => {
446+const sessionFile = path.join(tempDir, "session.jsonl");
447+const workspaceDir = path.join(tempDir, "workspace");
448+const firstHarness = createStartedThreadHarness();
449+450+const firstRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
451+nativeHookRelay: {
452+enabled: true,
453+events: ["pre_tool_use"],
454+},
455+});
456+await firstHarness.waitForMethod("turn/start");
457+const firstStartRequest = firstHarness.requests.find(
458+(request) => request.method === "thread/start",
459+);
460+const firstRelayId = extractRelayIdFromThreadRequest(firstStartRequest?.params);
461+const firstGeneration = extractGenerationFromThreadRequest(firstStartRequest?.params);
462+463+await firstHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
464+await firstRun;
465+expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
466+firstGeneration,
467+);
468+469+const secondHarness = createResumeHarness();
470+const secondParams = createParams(sessionFile, workspaceDir);
471+secondParams.runId = "run-2";
472+const secondRun = runCodexAppServerAttempt(secondParams, {
473+nativeHookRelay: {
474+enabled: true,
475+events: ["pre_tool_use"],
476+},
477+});
478+await secondHarness.waitForMethod("turn/start");
479+480+const resumeRequest = secondHarness.requests.find(
481+(request) => request.method === "thread/resume",
482+);
483+expect(extractRelayIdFromThreadRequest(resumeRequest?.params)).toBe(firstRelayId);
484+expect(extractGenerationFromThreadRequest(resumeRequest?.params)).toBe(firstGeneration);
485+486+await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
487+await secondRun;
488+testing.flushPendingCodexNativeHookRelayUnregistersForTests();
489+});
490+491+it("accepts a stale first hook generation when resuming a pre-generation binding", async () => {
492+const sessionFile = path.join(tempDir, "session.jsonl");
493+const workspaceDir = path.join(tempDir, "workspace");
494+await writeCodexAppServerBinding(sessionFile, {
495+threadId: "thread-existing",
496+cwd: workspaceDir,
497+model: "gpt-5.4-codex",
498+modelProvider: "openai",
499+dynamicToolsFingerprint: "[]",
500+});
501+const harness = createResumeHarness();
502+503+const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
504+nativeHookRelay: {
505+enabled: true,
506+events: ["pre_tool_use"],
507+},
508+});
509+await harness.waitForMethod("turn/start");
510+511+const resumeRequest = harness.requests.find((request) => request.method === "thread/resume");
512+const relayId = extractRelayIdFromThreadRequest(resumeRequest?.params);
513+const currentGeneration = extractGenerationFromThreadRequest(resumeRequest?.params);
514+expect(currentGeneration).not.toBe("legacy-generation-from-running-thread");
515+await expect(
516+invokeNativeHookRelay({
517+provider: "codex",
518+ relayId,
519+generation: "legacy-generation-from-running-thread",
520+event: "pre_tool_use",
521+requireGeneration: true,
522+rawPayload: {
523+hook_event_name: "PreToolUse",
524+tool_name: "Bash",
525+tool_use_id: "first-tool-after-restart",
526+tool_input: { command: "pwd" },
527+},
528+}),
529+).resolves.toMatchObject({ exitCode: 0 });
530+531+await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
532+await run;
533+expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
534+currentGeneration,
535+);
536+testing.flushPendingCodexNativeHookRelayUnregistersForTests();
537+});
538+539+it("rotates native hook relay generations when an existing binding starts a fresh thread", async () => {
540+const sessionFile = path.join(tempDir, "session.jsonl");
541+const workspaceDir = path.join(tempDir, "workspace");
542+await writeCodexAppServerBinding(sessionFile, {
543+threadId: "thread-existing",
544+cwd: workspaceDir,
545+model: "gpt-5.4-codex",
546+modelProvider: "openai",
547+userMcpServersFingerprint: "stale-user-mcp-fingerprint",
548+nativeHookRelayGeneration: "generation-from-stale-thread",
549+});
550+const harness = createStartedThreadHarness();
551+552+const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
553+nativeHookRelay: {
554+enabled: true,
555+events: ["pre_tool_use"],
556+},
557+});
558+await harness.waitForMethod("turn/start");
559+560+const startRequest = harness.requests.find((request) => request.method === "thread/start");
561+const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
562+const currentGeneration = extractGenerationFromThreadRequest(startRequest?.params);
563+expect(currentGeneration).not.toBe("generation-from-stale-thread");
564+await expect(
565+invokeNativeHookRelay({
566+provider: "codex",
567+ relayId,
568+generation: "generation-from-stale-thread",
569+event: "pre_tool_use",
570+requireGeneration: true,
571+rawPayload: {
572+hook_event_name: "PreToolUse",
573+tool_name: "Bash",
574+tool_use_id: "stale-thread-tool",
575+tool_input: { command: "pwd" },
576+},
577+}),
578+).rejects.toThrow("native hook relay bridge stale registration");
579+580+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
581+await run;
582+expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
583+currentGeneration,
584+);
585+testing.flushPendingCodexNativeHookRelayUnregistersForTests();
586+});
587+588+it("rotates native hook relay generations when resume fails over to a fresh thread", async () => {
589+const sessionFile = path.join(tempDir, "session.jsonl");
590+const workspaceDir = path.join(tempDir, "workspace");
591+await writeCodexAppServerBinding(sessionFile, {
592+threadId: "thread-existing",
593+cwd: workspaceDir,
594+model: "gpt-5.4-codex",
595+modelProvider: "openai",
596+nativeHookRelayGeneration: "generation-from-failed-resume",
597+});
598+const harness = createStartedThreadHarness(async (method) => {
599+if (method === "thread/resume") {
600+throw new Error("resume failed");
601+}
602+return undefined;
603+});
604+605+const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
606+nativeHookRelay: {
607+enabled: true,
608+events: ["pre_tool_use"],
609+},
610+});
611+await harness.waitForMethod("turn/start");
612+613+const startRequest = harness.requests.find((request) => request.method === "thread/start");
614+const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
615+const currentGeneration = extractGenerationFromThreadRequest(startRequest?.params);
616+expect(currentGeneration).not.toBe("generation-from-failed-resume");
617+await expect(
618+invokeNativeHookRelay({
619+provider: "codex",
620+ relayId,
621+generation: "generation-from-failed-resume",
622+event: "pre_tool_use",
623+requireGeneration: true,
624+rawPayload: {
625+hook_event_name: "PreToolUse",
626+tool_name: "Bash",
627+tool_use_id: "failed-resume-stale-tool",
628+tool_input: { command: "pwd" },
629+},
630+}),
631+).rejects.toThrow("native hook relay bridge stale registration");
632+633+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
634+await run;
635+expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
636+currentGeneration,
637+);
638+testing.flushPendingCodexNativeHookRelayUnregistersForTests();
639+});
640+443641it("builds deterministic opaque Codex native hook relay ids", () => {
444642const relayId = testing.buildCodexNativeHookRelayId({
445643agentId: "dev-codex",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。