From a0a305951c2ca46447b21457f8fd5af82eba2a30 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Wed, 28 Oct 2026 03:51:58 -0400 Subject: [PATCH] fix(codex): run the SessionStart hook when Codex forks a session Codex sends source "fork" for a forked session, but the plugin's SessionStart matcher stopped at compact, so the hook never ran: the forked session got no bootstrap package and no Session binding. The hook script and the Server already accept fork. Fixes #2287. --- docs/en/docs/integrations/codex.md | 2 +- docs/zh/docs/integrations/codex.md | 4 +- .../plugins/powercontext/hooks/hooks.json | 2 +- tests/codex_plugin/test_contract.py | 10 ++++- .../e2e/test_bootstrap_host_service_chain.py | 42 +++++++++++++------ 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/en/docs/integrations/codex.md b/docs/en/docs/integrations/codex.md index 47efe11404..776a73236d 100644 --- a/docs/en/docs/integrations/codex.md +++ b/docs/en/docs/integrations/codex.md @@ -136,7 +136,7 @@ a few Memory entries. Read [Memory and Handoff](../workflows/memory-and-handoff. ## Opt in to Session bootstrap context Bootstrap context is off by default. To load a small, deterministic package before Codex's first prompt on -`startup`, `resume`, `clear`, or `compact`, start Codex with: +`startup`, `resume`, `clear`, `compact`, or `fork`, start Codex with: ```bash export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true diff --git a/docs/zh/docs/integrations/codex.md b/docs/zh/docs/integrations/codex.md index 4a398d47ec..6cb8c831c0 100644 --- a/docs/zh/docs/integrations/codex.md +++ b/docs/zh/docs/integrations/codex.md @@ -112,8 +112,8 @@ Memory 用于长期保存可复用的决策、约束和状态;Handoff 用于 ## 选择启用 Session 启动上下文 -启动上下文默认关闭。若要在 Codex 的 `startup`、`resume`、`clear` 或 `compact` 事件中,于第一条 prompt 前加载一份 -小而确定的上下文包,请用以下环境启动 Codex: +启动上下文默认关闭。若要在 Codex 的 `startup`、`resume`、`clear`、`compact` 或 `fork` 事件中,于第一条 prompt 前 +加载一份小而确定的上下文包,请用以下环境启动 Codex: ```bash export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true diff --git a/integrations/codex/plugins/powercontext/hooks/hooks.json b/integrations/codex/plugins/powercontext/hooks/hooks.json index 6634346977..85a7d2c0b7 100644 --- a/integrations/codex/plugins/powercontext/hooks/hooks.json +++ b/integrations/codex/plugins/powercontext/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|clear|compact", + "matcher": "startup|resume|clear|compact|fork", "hooks": [ { "type": "command", diff --git a/tests/codex_plugin/test_contract.py b/tests/codex_plugin/test_contract.py index 2148905c04..7ee56fabff 100644 --- a/tests/codex_plugin/test_contract.py +++ b/tests/codex_plugin/test_contract.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import re import socket import threading import time @@ -267,7 +268,6 @@ def test_codex_hooks_fix_session_and_data_plane_bindings() -> None: configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) session_start = configuration["hooks"]["SessionStart"][0] - assert session_start["matcher"] == "startup|resume|clear|compact" session_hook = session_start["hooks"][0] assert "session_binding.py" in session_hook["command"] assert session_hook["additionalContextLimit"] == 8192 @@ -276,6 +276,14 @@ def test_codex_hooks_fix_session_and_data_plane_bindings() -> None: assert "bind_tools.py" in pre_tool_use["hooks"][0]["command"] +@pytest.mark.parametrize("source", ["startup", "resume", "clear", "compact", "fork"]) +def test_codex_session_start_hook_runs_for_every_lifecycle_codex_sends(source: str) -> None: + configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) + + (session_start,) = configuration["hooks"]["SessionStart"] + assert re.fullmatch(session_start["matcher"], source) + + def test_customer_artifact_workflow_does_not_parse_plugin_root_as_an_actions_expression() -> None: workflow = (REPOSITORY_ROOT / ".github" / "workflows" / "build-artifacts.yml").read_text() diff --git a/tests/e2e/test_bootstrap_host_service_chain.py b/tests/e2e/test_bootstrap_host_service_chain.py index e89aa59841..9e584f13d5 100644 --- a/tests/e2e/test_bootstrap_host_service_chain.py +++ b/tests/e2e/test_bootstrap_host_service_chain.py @@ -45,10 +45,33 @@ "claude-code": PROJECT_ROOT / "integrations" / "claude-code" / "plugins" / "powercontext", } BOOTSTRAP_TEXT = "HOST-REAL-BOOTSTRAP: use the exact packaged startup context chain." +# Codex 0.165.0-alpha.1.2 sent this payload to the plugin's SessionStart hook for a forked session. The session ID, +# working directory, and transcript path take this test's values; every other key and value is as Codex sent it. +CODEX_RECORDED_FORK = { + "session_id": "", + "transcript_path": "", + "cwd": "", + "hook_event_name": "SessionStart", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "source": "fork", +} -@pytest.mark.parametrize("host", ["codex", "claude-code"]) -def test_packaged_host_hooks_complete_the_live_bootstrap_chain(tmp_path: Path, host: str) -> None: +@pytest.mark.parametrize( + ("host", "session_start"), + [ + ("codex", {"hook_event_name": "SessionStart", "source": "startup"}), + ("claude-code", {"hook_event_name": "SessionStart", "source": "startup"}), + ("codex", CODEX_RECORDED_FORK), + ], + ids=["codex", "claude-code", "codex-recorded-fork"], +) +def test_packaged_host_hooks_complete_the_live_bootstrap_chain( + tmp_path: Path, + host: str, + session_start: dict[str, str], +) -> None: app = create_server_app( settings=ServerSettings( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / f'{host}.db'}"), @@ -70,17 +93,10 @@ def test_packaged_host_hooks_complete_the_live_bootstrap_chain(tmp_path: Path, h environment = _hook_environment(tmp_path, host, base_url, scope_id) session_id = f"{host}-bootstrap-session" - started = _run_hook( - plugin, - _session_start_script(host), - environment, - { - "hook_event_name": "SessionStart", - "source": "startup", - "session_id": session_id, - "cwd": str(PROJECT_ROOT), - }, - ) + payload = {**session_start, "session_id": session_id, "cwd": str(PROJECT_ROOT)} + if "transcript_path" in payload: + payload["transcript_path"] = str(tmp_path / f"{session_id}.jsonl") + started = _run_hook(plugin, _session_start_script(host), environment, payload) bootstrap = json.loads(started.stdout)["hookSpecificOutput"] assert bootstrap["hookEventName"] == "SessionStart" assert BOOTSTRAP_TEXT in bootstrap["additionalContext"]