SessionStart context is duplicated when editing a message or forking a thread
What issue are you seeing?
Editing an earlier message creates a forked thread. The fork copies the existing conversation history—including previous SessionStart developer context—then runs the startup hook again and injects a second copy.
Repeated edits can therefore accumulate duplicate context and consume the context window unnecessarily.
This was observed on codex-cli 0.148.0 on Linux x86_64
What steps can reproduce the bug?
For easy reproduction, and to avoid requiring users to write or configure their own hooks, the attached script creates a temporary workspace and installs a temporary SessionStart hook there.
It then drives Codex through the app-server protocol, submits two prompts, forks after the first turn to emulate editing the second prompt, and counts marker-bearing developer-context messages in the resulting history. The temporary workspace and hook files are removed afterward; the generated Codex sessions remain available for inspection.
- Attach
reproduce_codex_session_start_branch.py. - Run:
``bash``
python3 reproduce_codex_session_start_branch.py
Example output with codex-cli 0.148.0 on Linux x86_64:
SessionStart hook executions recorded: 2
Parent context visible to the model: 1 copy
Copies stored directly in the branch rollout: 2 copies
Copies reached through parent-history references: 0 copies
Total branch context visible to the model: 2 copies
REPRODUCED: the edited-message branch has duplicate SessionStart context.
This proves that the hook runs again and its output is added alongside the copy inherited from the parent conversation. Consequently, the model receives two identical copies.
Each additional edit or fork can introduce another copy.
reproduce_codex_session_start_branch.py
What is the expected behavior?
The edited-message branch should contain only one effective copy of the SessionStart context.
Codex could achieve this by not rerunning inherited SessionStart context, replacing the previous context, or deduplicating hook output.
Additional information
The documented SessionStart.source values are startup, resume, clear, and compact. There is no fork or message_edit source that allows a hook to distinguish this case.
Related request for exposing message-edit rollbacks to hooks: #28228
Note that Claude Code 2.1.214 introduced a 'fork' source for the SessionStart hook See https://code.claude.com/docs/en/changelog#2-1-214 and https://code.claude.com/docs/en/hooks#sessionstart. A 'fork' source would solve my problem, and would contribute towards #21753 (although I don't see this problem mentioned there yet).
2 Comments
I traced this against current
main(8e649e3afa5cdddfb09a1b85a090b94775045d9b). The duplication mechanism is explicit in the current lifecycle:Session::new()mapsInitialHistory::Forked(_)toSessionStartSource::Startup.StartupSessionStart source.run_pending_session_start_hooks()runs the hook and records its returned context as new developer messages.So a fork inherits the parent's SessionStart context and then runs a second startup hook after the copy.
There is an important constraint on the possible fix: hook-injected context currently has no persisted provenance that identifies which hook event produced a developer message.
HookAdditionalContextis converted to an ordinary developerResponseItem, andResponseItemEnvelope::CodexHarnessMetadatacurrently only tracksclient_authored. That means a fork cannot safely remove 'the inherited SessionStart context' without either adding provenance or relying on content/order heuristics. Text-based deduplication would also conflate SessionStart output with developer context produced by other hook types.Adding a
forkvalue toSessionStartSourceis useful and compatible with the reporter's stated use case: a handler matchingstartupwould no longer run on a fork, and fork-aware handlers could opt intofork. However it does not by itself guarantee one effective copy for match-all SessionStart handlers (matcher: None/*), because those handlers match every source and would still inject another context copy. So I don't think changing onlyStartup -> Forkis a complete fix for the issue title.The two complete semantics I see are:
forkSessionStart hook.The second is the cleaner lifecycle model if Codex wants Claude-style
source: fork, but it is a wider persistence/history change. I did not submit a heuristic patch because the current data model cannot distinguish SessionStart developer context from other hook-injected developer context safely. The immediate minimal API improvement would be aforksource, but that should not be described as universal deduplication unless match-all handler behavior is intentionally changed too.---
Update — minimal
source: forkpatch validated and publishedI implemented the narrow API/lifecycle improvement above from exact upstream base
c9b19deb09c1841ce7acc33ddb96276030936a29:060f953f596d7ec818e459cd7256be860fad73fdThe source branch is one commit ahead / zero behind that exact base and changes only five intended files. The patch:
SessionStartSource::Fork/ schema value"fork";InitialHistory::Forked(_)toForkinstead ofStartup;["startup", "fork"];The isolated validation run completed successfully. It regenerated/checked the schema, ran the fork E2E regression, ran the complete
codex-hookstest crate, ran Rust lint/format gates, rejected unexpected source changes, reran the focal regressions after lint, verified the exact five-file diff, and only then published the source branch. The validation-only PR in my fork was closed without merge after the run.I also attempted to open the upstream PR, but GitHub returned
403 Resource not accessible by integrationfor the cross-fork PR creation path. So the branch + SHA above are the submit-ready artifact and evidence.Scope caveat remains: this patch provides an explicit
forksource and fixes startup-only handlers rerunning on forks. It intentionally does not claim universal context deduplication for match-all SessionStart handlers; that still needs the broader lifecycle/provenance decision described above.Follow-up with the final patch artifact and current-main revalidation (without repeating the lifecycle trace above):
charle-z/codex:fix/39951-session-start-fork-source-v3060f953f596d7ec818e459cd7256be860fad73fd(fix(core): distinguish fork session start hooks)mainchecked:e21bc763a72adb982522032ce1be725b691dc342The final patch adds
SessionStartSource::Forkend-to-end:InitialHistory::Forked(_)queuesFork, matcher input / hook stdin serialize it as"fork", and both the source schema and committed generated schema enumeratefork. The realfork_threadintegration test asserts the observed SessionStart sequence is exactly["startup", "fork"]for parent + fork.The isolated v3 validation was green for schema regeneration/fixture contract, the real
fork_threadE2E,codex-hooks, formatting/lint/fix, and exact diff auditing. I also reapplied the final commit cleanly on the current upstream SHA above. Upstream has advanced five commits since the patch parent (c9b19deb09c1841ce7acc33ddb96276030936a29), but none modifies the five files in this patch; the only recent fork-history-related change I found (#40266) preserves content annotations for spawned-agent history and does not replace this SessionStart lifecycle fix.Scope note: this patch deliberately does not delete or deduplicate inherited hook context. A generic SessionStart handler that matches all sources can still choose to run on a fork and inject the same text again. The fix supplies the missing fork lifecycle semantic so hook configuration can distinguish startup vs fork; there still is not event-specific persisted provenance that would make selective inherited-SessionStart cleanup safe.
The upstream cross-fork PR path remains blocked for this contributor, so the SHA above is the reviewable source artifact.