From 80bc7026751dea148940253b289dd275cb3b4eaf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 24 Apr 2026 00:41:16 +0100 Subject: [PATCH] Land Phase 3 Epiphany thread state exposure --- README.md | 19 +- notes/codex-epiphany-mode-plan.md | 271 ++++++--------- notes/fresh-workspace-handoff.md | 75 +++-- state/evidence.jsonl | 3 + state/map.yaml | 7 +- .../analytics/src/analytics_client_tests.rs | 1 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 6 + vendor/codex/codex-rs/app-server/README.md | 4 + .../app-server/src/codex_message_processor.rs | 165 ++++++++- .../codex-rs/app-server/src/thread_status.rs | 1 + .../codex/codex-rs/core/src/codex_thread.rs | 5 + .../codex-rs/core/src/epiphany_rollout.rs | 318 ++++++++++++++++++ vendor/codex/codex-rs/core/src/lib.rs | 2 + vendor/codex/codex-rs/exec/src/lib_tests.rs | 2 + .../tui/src/app/app_server_adapter.rs | 2 + .../codex-rs/tui/src/app/loaded_threads.rs | 1 + vendor/codex/codex-rs/tui/src/app/tests.rs | 4 + .../codex-rs/tui/src/app_server_session.rs | 1 + .../codex/codex-rs/tui/src/resume_picker.rs | 1 + 20 files changed, 685 insertions(+), 204 deletions(-) create mode 100644 vendor/codex/codex-rs/core/src/epiphany_rollout.rs diff --git a/README.md b/README.md index 5d73e174..389e6dcb 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ The working design now is: - `vendor/codex` is the real target and is tracked directly in the parent repo - third-party OpenCodex forks were audited and removed - the Codex machine map and Epiphany harness spec have both been written and iterated enough to stop hand-waving -- Phase 1 durable Epiphany state and Phase 2 prompt integration are both landed and verified -- the next concrete step is typed app-server/protocol state exposure for clients +- Phase 1 durable Epiphany state, Phase 2 prompt integration, and a minimal Phase 3 typed client read surface are all landed and verified +- the next concrete step is the repo-local hybrid retrieval subsystem ## Current Slice Status @@ -66,11 +66,18 @@ Phase 2 landed: - inject it during `Session::build_initial_context` - verify inclusion, omission, resume, and snapshot behavior -Phase 3 is next: +Phase 3 landed: -- expose typed Epiphany state to clients over app-server/protocol surfaces -- keep it additive and internal/dev-usable first -- do not make prompt text the source of truth for GUI state +- add optional typed `epiphanyState` to hydrated app-server `Thread` payloads +- hydrate it from live loaded-thread state when available +- fall back to rollout reconstruction with rollback/compaction semantics for stored thread reads +- keep dedicated Epiphany update RPCs and live notifications deferred for now + +Phase 4 is next: + +- add a repo-local hybrid retrieval subsystem +- expose typed retrieval state and a typed retrieval query surface +- stop paying the full file-by-file shell tax for every mapping or implementation pass ## Basic Loop diff --git a/notes/codex-epiphany-mode-plan.md b/notes/codex-epiphany-mode-plan.md index 5c789d8e..cfa592a4 100644 --- a/notes/codex-epiphany-mode-plan.md +++ b/notes/codex-epiphany-mode-plan.md @@ -1,230 +1,165 @@ -# Codex Epiphany Phase 2 Implementation Plan +# Codex Epiphany Phase 4 Implementation Plan ## Status -Implemented and verified in vendored Codex on 2026-04-23. +Planned on 2026-04-23 after landing and verifying: -What landed: +- Phase 1 durable Epiphany thread state +- Phase 2 prompt integration +- a minimal Phase 3 typed client read surface via `Thread.epiphanyState` -- `EpiphanyStateInstructions` as a dedicated developer-context fragment -- a bounded `EpiphanyThreadState` renderer wrapped in ` ... ` -- prompt injection from `Session::build_initial_context` whenever `SessionState.epiphany_state` is present -- prompt-facing inclusion, omission, resume, bounded-rendering, and snapshot coverage - -Next likely slice: - -- Phase 3 typed app-server/protocol exposure so GUI clients can read Epiphany thread state directly instead of inferring it from prompt text - -This is the actionable implementation note for the second Epiphany patch slice against the real `openai/codex` tree now vendored directly under `vendor/codex`. - -Phase 1 landed in parent commit `2042687e3035c5a86d7f6aa66306d87abcc10f2d`. The durable state seam exists. Codex can now persist and replay structured Epiphany thread state. The next job is to make that state actually matter to the turn loop without pretending we already have GUI, retrieval, or operator ergonomics solved. +The next job is to give Epiphany a real repo retrieval organ instead of making every future role rediscover the workspace with shell commands and optimism. ## Summary -Land Phase 2 as a **prompt-integration slice** that derives a compact Epiphany summary from `SessionState.epiphany_state` and injects it into the developer-context path during turn construction. +Land Phase 4 as a **bounded internal retrieval slice**: + +- add typed retrieval state and shard/index summaries to Epiphany state +- add one additive app-server retrieval query surface for loaded threads +- support hybrid retrieval from day one: + - exact/path/symbol/lexical results + - semantic chunk results +- keep the slice internal/dev-usable first After this slice: -- Epiphany state affects model behavior instead of just sitting in rollout -- resumed Epiphany sessions carry their structured understanding back into the prompt path -- normal non-Epiphany sessions still behave exactly as they do now +- Epiphany can ask structured repo questions through one typed surface +- GUI work has a real data source later +- retrieval can evolve without making prompt text or shell transcripts the canonical knowledge path -This slice still does **not** include: +This slice should still avoid: -- GUI surfaces -- app-server notifications or typed read/update RPCs -- retrieval indexing -- watcher-driven semantic invalidation -- observation promotion heuristics +- watcher-driven invalidation +- automatic observation promotion +- GUI implementation - specialist-agent scheduling -- public operator UX +- user-facing activation flows -## Phase 2 Principle +## Phase 4 Principle -The activation rule for Phase 2 should stay brutally simple: +Do not build "vector search" as a separate novelty organ off to the side. -- if `SessionState.epiphany_state` is `Some`, the session is Epiphany-active -- if it is `None`, nothing new is injected - -That is enough for internal/dev use. Do not invent a new public toggle, preset, or protocol field in this slice. +Build a **hybrid repo retrieval subsystem** with one typed query shape and one typed result shape. Exact lookup and semantic lookup should be different modes of the same machine, not rival religions. ## Key Changes -### 1. Add a dedicated Epiphany prompt fragment renderer +### 1. Extend Epiphany state with retrieval metadata Touch: -- `vendor/codex/codex-rs/core/src/context/mod.rs` -- `vendor/codex/codex-rs/core/src/context/epiphany_state_instructions.rs` (new) +- `vendor/codex/codex-rs/protocol/src/protocol.rs` -Add a new contextual fragment, tentatively: +Add minimal retrieval metadata to `EpiphanyThreadState`, for example: -- `EpiphanyStateInstructions` +- `retrieval` + - `workspace_root` + - `index_revision` + - `status` + - `semantic_available` + - `last_indexed_at` + - `shards` + - `dirty_paths` -Pattern it after other developer-scoped context fragments such as collaboration-mode and permissions instructions. +Keep this metadata summary-focused. Do not dump raw embeddings, giant posting lists, or per-file sludge into thread state. + +### 2. Add a core retrieval facade + +Touch: + +- `vendor/codex/codex-rs/core/src/` + +Add a focused retrieval module, tentatively: + +- `epiphany_retrieval.rs` Responsibilities: -- render a compact, deterministic textual summary from `EpiphanyThreadState` -- wrap it in its own clear developer marker block such as ` ... ` -- include light instruction text that tells the model how to treat the state: - - use it as the current structured thread understanding - - do not silently redefine it - - surface mismatches or gaps before broad edits +- accept a loaded thread plus query params +- resolve the relevant workspace root +- run exact retrieval and semantic retrieval behind one interface +- return typed ranked results with file paths, line anchors, excerpts, and retrieval mode metadata -Do **not** dump raw JSON into the prompt. +Result types should be explicit and bounded: -### 2. Keep the summary bounded and intentionally selective +- exact hits +- semantic hits +- optional graph-linked ids later -The renderer should include only the parts that help the model stay oriented during a turn. +Do not make the first cut depend on prompt parsing or transcript scraping. -Include: +### 3. Keep the first semantic backend small and local -- `revision` -- `objective` -- `active_subgoal_id` -- a bounded list of nearby `subgoals` -- a bounded list of `invariants` -- a compact `graphs` summary centered on the frontier -- `graph_frontier` -- `graph_checkpoint` -- recent `observations` -- recent `recent_evidence` -- `churn` -- `mode` -- `last_updated_turn_id` +The first semantic backend should be workspace-local, sharded, and incremental-friendly in storage layout, even if the initial indexing policy is still crude. -Default Phase 2 shaping rules: +Requirements: -- prefer frontier and checkpoint over whole-graph dumping -- prefer active and recent records over exhaustive history -- cap list lengths aggressively -- keep code refs line-anchored when rendered, but do not spam them everywhere -- omit empty sections entirely +- do not store everything in one giant JSON blob +- prefer per-workspace or per-subsystem shards +- track index revision and freshness explicitly +- keep chunk text line-anchored and path-aware -Phase 2 should **not** naively dump full scratch content. Scratch is volatile and can turn the prompt back into sludge. If scratch is rendered at all, keep it to a tiny active-summary form. +If the first semantic backend needs to be intentionally narrow, that is fine. A small honest subsystem beats a magical blob with terrible write behavior. -### 3. Inject the Epiphany summary during initial context construction +### 4. Add one additive app-server query surface Touch: -- `vendor/codex/codex-rs/core/src/session/mod.rs` +- `vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs` +- `vendor/codex/codex-rs/app-server/src/codex_message_processor.rs` +- `vendor/codex/codex-rs/app-server/README.md` -The integration point is: +Add one typed method for retrieval, tentatively: -- `Session::build_initial_context` +- `thread/epiphany/retrieve` -Add: +Suggested request fields: -- a read of `self.epiphany_state().await` -- conditional rendering of `EpiphanyStateInstructions` when the state exists -- insertion into `developer_sections` +- `threadId` +- `query` +- optional `cwd` +- optional `modes` or `preferSemantic` +- optional `limit` +- optional `pathPrefixes` -Recommended placement: +Suggested response fields: -- after collaboration-mode instructions -- before realtime/personality/apps/skills/plugin additions +- `query` +- `indexSummary` +- `results` -That keeps the Epiphany state near the top-level task discipline instead of burying it behind auxiliary capability chatter. +Keep it additive and read-only. No write/update/index-control methods yet unless they turn out to be mechanically unavoidable. -Do **not** change: +### 5. Reuse existing exact-search substrate where practical -- `TurnContext` -- rollout protocol -- tool routing -- token accounting rules +Codex already has fuzzy/exact-ish repo search substrate. Reuse what is useful instead of building a second exact-search toy from scratch. -unless a small mechanical adjustment becomes unavoidable during implementation. +But do not confuse that with the whole answer. Phase 4 exists because exact file search alone is not enough. -### 4. Treat prompt integration as read-only in this slice +### 6. Test the machine, not just the types -Phase 2 is about **consuming** Epiphany state during turns, not teaching the model to rewrite canon by itself. +Add tests for: -So in this slice: - -- the model reads Epiphany state -- the existing persistence seam remains the storage mechanism -- no automatic state mutation from assistant output happens yet - -If state updates are needed during testing, use the internal/dev hooks that already exist from Phase 1. - -### 5. Add prompt-facing tests, not just persistence tests - -Touch: - -- `vendor/codex/codex-rs/core/src/session/tests.rs` -- `vendor/codex/codex-rs/core/src/session/snapshots/` (if a new snapshot is needed) -- optionally `vendor/codex/codex-rs/core/src/context/epiphany_state_instructions.rs` local tests - -Add at least: - -1. `build_initial_context_includes_epiphany_state_block_when_present` - - seed `SessionState.epiphany_state` - - call `build_initial_context` - - verify the developer bundle includes the Epiphany block - -2. `build_initial_context_omits_epiphany_state_block_when_absent` - - verify normal sessions stay unchanged - -3. one bounded-rendering test - - prove the renderer prefers frontier/recent items and omits empty sections - -4. one snapshot-style request/context test - - similar in spirit to the existing fork/context snapshot coverage - - confirm Epiphany state appears where expected in the built prompt - -If practical, add one resume-oriented assertion: - -- reconstruct a session from rollout with Epiphany state -- build initial context -- verify the injected Epiphany block survives replay and resume - -## Public Interfaces - -Phase 2 should still avoid public surface churn. - -No new: - -- protocol events -- app-server notifications -- GUI methods -- public activation controls - -This is still an internal/dev-usable slice. +1. protocol serde for the new retrieval request/response shapes +2. core ranking/merging behavior for mixed exact + semantic results +3. app-server handling for the new request +4. retrieval metadata inclusion in `EpiphanyThreadState` +5. a smoke test that proves semantic retrieval can find a concept that exact-name search would miss ## Assumptions -- The existence of `SessionState.epiphany_state` is sufficient as the Phase 2 activation signal. -- The Epiphany prompt block belongs in the developer-context path, not as a user message. -- Prompt integration should remain compact and deterministic; this slice is not permission to stuff the entire map back into one giant haunted string. -- Phase 2 is still safe to ship without GUI changes because non-Epiphany sessions remain untouched. - -## Test Plan - -### Core behavior - -- Epiphany-active sessions inject a developer-scoped Epiphany summary -- non-Epiphany sessions do not -- resumed sessions with persisted Epiphany state inject the latest surviving summary - -### Rendering discipline - -- empty fields do not create empty sections -- bounded sections truncate deterministically -- frontier/gap/evidence ordering is stable - -### Compatibility - -- existing prompt/context tests continue to pass for non-Epiphany paths -- no new rollout compatibility fallout should appear because Phase 2 consumes existing state only +- Phase 4 is still internal/dev-usable only. +- Retrieval should be thread/workspace aware, not a global cross-repo soup. +- The first retrieval slice can be read-only. +- Watcher-driven invalidation belongs later; this slice only needs enough metadata to admit freshness honestly. ## Immediate Next Step After This Plan -Implement the smallest useful prompt path only: +Implement the smallest useful retrieval baseline: -1. add `EpiphanyStateInstructions` -2. inject it from `Session::build_initial_context` when `epiphany_state` exists -3. add omission/inclusion tests -4. add one snapshot proving the prompt shape +1. extend `EpiphanyThreadState` with retrieval metadata +2. add a core hybrid retrieval facade +3. expose one typed `thread/epiphany/retrieve` read method +4. document it in `app-server/README.md` +5. verify with targeted protocol/core/app-server tests -Do not start with GUI. Do not start with retrieval. Do not start teaching the model to rewrite canon from its own prose. +Do not start with GUI. Do not start with automatic graph invalidation. Do not pretend shell transcripts are a retrieval strategy. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1418bf3d..2422f5e2 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -11,7 +11,7 @@ The working idea is simple enough: - keep evidence durable - make the harness consult that state instead of pretending the transcript is a brain -The project target is now the vendored Codex core harness itself, not a sidecar wrapper first and not some random GitHub “OpenCodex” contraption held together with hope and string. +The project target is now the vendored Codex core harness itself, not a sidecar wrapper first and not some random GitHub "OpenCodex" contraption held together with hope and string. ## Current Repo State @@ -25,6 +25,7 @@ Recent anchor commits before the current in-flight work: - `2042687e3035c5a86d7f6aa66306d87abcc10f2d` - vendored Codex and landed the Phase 1 Epiphany core state slice - `c823815` - persisted the Phase 2 implementation plan and refreshed the project handoff/state - `efd1420` - landed and pushed the Phase 2 prompt-integration slice +- `640e063` - documented the pre-compaction Epiphany workflow and synced the repo memory before compaction Do not trust this note for the exact current HEAD; use `git log --oneline -1` if you need the live commit. @@ -66,7 +67,7 @@ Main touched files: - `vendor/codex/codex-rs/core/src/session/rollout_reconstruction_tests.rs` - plus rollout/state/app-server compatibility readers -Phase 2 is now done too. +Phase 2 is done too. The Phase 2 prompt-integration slice was implemented inside vendored Codex: @@ -85,23 +86,48 @@ Main touched files for Phase 2: - `vendor/codex/codex-rs/core/src/session/snapshots/codex_core__session__build_initial_context_epiphany_state.snap` - `vendor/codex/codex-rs/protocol/src/protocol.rs` +Phase 3 is landed too, in a conservative read-surface form. + +What landed: + +- app-server protocol `Thread` payloads now expose optional typed `epiphany_state` +- hydrated thread responses prefer live `CodexThread` state when a thread is loaded +- stored thread reads fall back to rollout reconstruction through a shared core helper that respects rollback and compaction semantics +- the minimal typed client surface is now real without making prompt text the GUI data source +- dedicated Epiphany-specific update RPCs and live state notifications are still deferred + +Main touched files for Phase 3: + +- `vendor/codex/codex-rs/core/src/epiphany_rollout.rs` +- `vendor/codex/codex-rs/core/src/lib.rs` +- `vendor/codex/codex-rs/core/src/codex_thread.rs` +- `vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs` +- `vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs` +- `vendor/codex/codex-rs/app-server/src/codex_message_processor.rs` +- `vendor/codex/codex-rs/app-server/README.md` +- compatibility fixture/test updates in `analytics`, `exec`, and `tui` + ## What Must Be Remembered Before Compaction If a future session wakes up from compaction and starts bluffing, this is the part to staple to its forehead. -- Phase 1 and Phase 2 are both landed, verified, committed, and pushed. -- The latest pushed implementation anchor before this handoff sync is `efd1420`: - - `Land Phase 2 Epiphany prompt integration` +- Phase 1, Phase 2, and the minimal Phase 3 read surface are all landed and verified. +- The latest pushed implementation anchor before this handoff sync is `640e063`: + - `Document pre-compaction Epiphany workflow` - `vendor/codex` is ordinary tracked repo content, not a submodule. - Phase 2 means Codex now **reads** Epiphany state during turn construction: - `SessionState.epiphany_state` is the internal activation signal - `Session::build_initial_context` injects a bounded `` developer fragment - resumed sessions reuse the restored Epiphany snapshot in the prompt path +- Phase 3 means clients can now **read** typed Epiphany state directly from hydrated `Thread` payloads: + - `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, `thread/unarchive`, and detached review-thread startup can carry `thread.epiphanyState` when present + - loaded threads use live state + - stored thread reads reconstruct from rollout with the same rollback/compaction semantics as core + - there are still no dedicated Epiphany update RPCs or live `thread/epiphany/*` notifications - The next phase is **not** GUI work. -- The next phase is **Phase 3 typed state exposure**: - - expose Epiphany thread state to clients over typed app-server/protocol surfaces - - keep it additive and internal/dev-usable first - - do not make prompt text the canonical GUI data source +- The next phase is **Phase 4 repo-local hybrid retrieval**: + - give Epiphany a real repo retrieval subsystem instead of repeated file-by-file shell archaeology + - keep it typed, additive, and GUI-friendly - Important Windows verification footgun still stands: - use `CARGO_TARGET_DIR=C:\Users\Meta\.cargo-target-codex` for `codex-core` work on this machine - Snapshot hygiene note: @@ -138,6 +164,12 @@ Verified: - targeted new `codex-core` Epiphany persistence/replay tests passed - targeted `codex-core` `epiphany` tests passed after Phase 2 - broader `codex-core` `build_initial_context_*` coverage passed after Phase 2 +- `cargo fmt --all` passed again after Phase 3 +- `cargo test -p codex-core -p codex-app-server-protocol -p codex-app-server -p codex-analytics -p codex-exec -p codex-tui --lib --no-run` passed after the new `Thread.epiphanyState` field fallout was patched +- targeted Phase 3 tests passed: + - `cargo test -p codex-core --lib latest_epiphany_state_from_rollout_items` + - `cargo test -p codex-app-server --lib load_epiphany_state_from_rollout_path_reads_latest_snapshot` + - `cargo test -p codex-app-server-protocol --lib serialize_client_response` Important Windows footgun: @@ -149,39 +181,38 @@ Without that, you get to learn about `symlink_dir failed: ... A required privile ## Recommended Next Implementation -Do **Phase 3 typed state exposure** next. +Do **Phase 4 repo-local hybrid retrieval** next. -The durable state seam exists and the turn loop now reads it. The next clean move is to expose that state to clients without forcing GUI code to scrape prompt text. +The durable state seam exists, the turn loop now reads it, and clients can now load typed Epiphany state directly. The next clean move is to stop making every future agent rediscover the repo with shell spelunking. Planned slice: -1. add typed app-server/protocol read surfaces for Epiphany thread state -2. keep the new surface additive and internal/dev-usable first -3. avoid making transcript text the canonical source for GUI state -4. leave retrieval, invalidation, and specialist-agent scheduling for later slices +1. define retrieval state and shard/index summaries in typed Epiphany state +2. add a bounded hybrid retrieval surface that combines exact search with semantic chunk lookup +3. keep the first retrieval slice additive and internal/dev-usable first +4. leave watcher-driven invalidation, heavy GUI reflection, and specialist-agent scheduling for later slices Main files for the next slice: +- `vendor/codex/codex-rs/protocol/src/` +- `vendor/codex/codex-rs/core/src/` - `vendor/codex/codex-rs/app-server/src/` - `vendor/codex/codex-rs/app-server-protocol/src/` -- `vendor/codex/codex-rs/protocol/src/` -- `vendor/codex/codex-rs/core/src/session/` ## What Not To Do Next Not yet: - GUI work -- protocol event expansion -- retrieval indexing +- watcher-driven semantic invalidation - automatic observation promotion - specialist-agent scheduling - user-facing activation flows The next slice should stay small and mean: -- expose typed state reads -- keep GUI, retrieval, and mutation logic dark +- add typed retrieval scaffolding +- keep GUI, mutation, and specialist-agent logic dark ## Verification Commands @@ -203,4 +234,4 @@ cmd /c "\"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxi The repo is in a good state. -Phase 1 and Phase 2 are landed and verified. `vendor/codex` is first-class in the parent repo now. The next clean move is typed state exposure for clients, not GUI paint and not another architectural detour. Also: pre-compaction persistence is now an explicit design rule, not a lucky habit. +Phase 1, Phase 2, and a minimal Phase 3 typed read surface are landed and verified. `vendor/codex` is first-class in the parent repo now. The next clean move is repo-local hybrid retrieval, not GUI paint and not another architectural detour. Also: pre-compaction persistence is now an explicit design rule, not a lucky habit. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index fc6048df..a14d3d70 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -32,3 +32,6 @@ {"ts": "2026-04-23T23:25:52+01:00", "type": "decision", "status": "ok", "note": "Replaced the stale Phase 1 plan and stale workspace handoff with current persisted guidance. The new notes pin the next slice as Phase 2 prompt integration: render a bounded Epiphany developer-context summary from SessionState.epiphany_state, inject it during Session::build_initial_context, keep activation internal-only via state presence, and verify it with prompt-facing tests and a snapshot before touching GUI or retrieval."} {"ts": "2026-04-23T22:48:30+00:00", "type": "implementation", "status": "ok", "note": "Implemented the Phase 2 prompt-integration slice in vendored Codex. Added a dedicated EpiphanyStateInstructions developer fragment, rendered a bounded summary from SessionState.epiphany_state, injected it into Session::build_initial_context after collaboration-mode instructions, and added prompt-facing inclusion/omission/resume coverage plus a stable snapshot."} {"ts": "2026-04-23T22:55:33+00:00", "type": "decision", "status": "ok", "note": "Documented the pre-compaction persistence workflow as an explicit Epiphany design rule. Updated the core harness spec, README, handoff note, and canonical map to state that Epiphany should be an opinionated software development agent: before compaction or handoff it should persist map, evidence, blockers, and next action on purpose rather than relying on transcript continuity."} +{"ts": "2026-04-24T00:39:29+01:00", "type": "decision", "status": "ok", "note": "Delivered Phase 3 as a conservative typed read-surface slice instead of full live event/update exposure. Added optional epiphanyState to hydrated app-server Thread payloads and kept dedicated Epiphany-specific update RPCs and live notifications deferred so the client can read canonical state without scraping prompt text."} +{"ts": "2026-04-24T00:39:29+01:00", "type": "implementation", "status": "ok", "note": "Implemented the Phase 3 typed client exposure slice in vendored Codex. Added a shared core helper to reconstruct the latest surviving EpiphanyThreadState from rollout items, exposed live thread epiphany_state through CodexThread, added optional Thread.epiphany_state in app-server protocol v2, hydrated thread/start, thread/resume, thread/fork, thread/read, thread/unarchive, and detached review-thread startup from live state or rollout, patched exhaustive Thread test fixtures, and documented the new field in app-server/README.md."} +{"ts": "2026-04-24T00:39:29+01:00", "type": "verification", "status": "ok", "note": "Verified the Phase 3 slice with the Windows CARGO_TARGET_DIR workaround. cargo fmt --all passed; cargo test -p codex-core -p codex-app-server-protocol -p codex-app-server -p codex-analytics -p codex-exec -p codex-tui --lib --no-run passed; and targeted tests passed for codex-core latest_epiphany_state_from_rollout_items, codex-app-server load_epiphany_state_from_rollout_path_reads_latest_snapshot, and codex-app-server-protocol serialize_client_response."} diff --git a/state/map.yaml b/state/map.yaml index 63955730..ad1bb564 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -45,7 +45,7 @@ data_flow: - commit or backtrack active_subgoals: - - Decide and slice the Phase 3 typed app-server/protocol exposure for Epiphany thread state now that Phase 2 prompt integration is landed and verified. + - Define the Phase 4 repo-local hybrid retrieval slice now that the minimal Phase 3 typed client exposure is landed and verified. - Use the tiny CLI and typed state files on a small real task. - Decide whether the next executable artifact should be a patch runner or a verifier helper. - Run the protocol on a small real task. @@ -74,6 +74,7 @@ accepted_design: - vendor Codex directly into the parent repo instead of keeping it as a gitlink/submodule so Epiphany harness changes can be reviewed and committed atomically with the surrounding project state - for Phase 2, treat the presence of SessionState.epiphany_state as the internal activation signal and inject a bounded Epiphany summary into the developer-context path during build_initial_context rather than inventing a public operator surface early - Phase 2 is landed: a dedicated EpiphanyStateInstructions fragment now renders bounded Epiphany thread state into the developer prompt path during build_initial_context, with omission/inclusion/resume coverage and a stable snapshot + - Phase 3 read-surface slice is landed: app-server protocol Thread payloads now carry optional typed epiphany_state hydrated from live CodexThread state when available and otherwise reconstructed from rollout with rollback/compaction semantics; dedicated Epiphany update RPCs and live notifications remain deferred - Epiphany should be an opinionated software development agent: pre-compaction and pre-handoff persistence of map, evidence, and next action are part of the workflow, not optional operator folklore rejected_paths: @@ -91,5 +92,5 @@ rejected_paths: reason: audited candidates were either terminal-only, stale/aspirational, or incoherent wrappers; the comparison clones were removed from vendor to avoid building on untrusted drift current_status: - summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. The Phase 1 internal/dev-usable Epiphany core state slice and the Phase 2 prompt-integration slice are both implemented and verified with a real Rust/MSVC toolchain; vendored Codex is tracked directly in the parent repo; the next move is to expose typed Epiphany state to clients without making prompt text the source of truth; and pre-compaction persistence is now an explicit part of the intended Epiphany workflow. - next_action: Decide and plan the Phase 3 typed app-server/protocol exposure slice so clients can read Epiphany thread state directly, keeping the surface additive and internal/dev-usable before GUI ergonomics or retrieval work. + summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. Phase 1 durable Epiphany state, Phase 2 prompt integration, and a minimal Phase 3 typed client read surface are all implemented and verified with a real Rust/MSVC toolchain; vendored Codex is tracked directly in the parent repo; app-server Thread payloads can now carry typed Epiphany state without scraping prompt text; and pre-compaction persistence is now an explicit part of the intended Epiphany workflow. + next_action: Write and then implement the first bounded Phase 4 repo-local hybrid retrieval slice so Epiphany can query repo knowledge through typed state and additive query surfaces instead of repeated file-by-file shell archaeology. diff --git a/vendor/codex/codex-rs/analytics/src/analytics_client_tests.rs b/vendor/codex/codex-rs/analytics/src/analytics_client_tests.rs index 9b45a1a8..f65c9262 100644 --- a/vendor/codex/codex-rs/analytics/src/analytics_client_tests.rs +++ b/vendor/codex/codex-rs/analytics/src/analytics_client_tests.rs @@ -137,6 +137,7 @@ fn sample_thread_with_source( agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), } } diff --git a/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs b/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs index a751f11e..a15fbc48 100644 --- a/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1461,6 +1461,7 @@ mod tests { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), }, model: "gpt-5".to_string(), diff --git a/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs b/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs index 1f23c7b5..82b540c1 100644 --- a/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -57,6 +57,7 @@ use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::EpiphanyThreadState as CoreEpiphanyThreadState; use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; @@ -4431,6 +4432,11 @@ pub struct Thread { pub git_info: Option, /// Optional user-facing thread title. pub name: Option, + /// Only populated on `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, + /// and notifications/responses that reuse those hydrated thread payloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub epiphany_state: Option, /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` /// (when `includeTurns` is true) responses. /// For all other responses and notifications returning a Thread, diff --git a/vendor/codex/codex-rs/app-server/README.md b/vendor/codex/codex-rs/app-server/README.md index 15b7f9a5..46f242dd 100644 --- a/vendor/codex/codex-rs/app-server/README.md +++ b/vendor/codex/codex-rs/app-server/README.md @@ -136,6 +136,8 @@ Example with notification opt-out: ## API Overview +Hydrated thread responses such as `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, and `thread/unarchive` may include `thread.epiphanyState` when the thread has persisted Epiphany state. When the thread is currently loaded, app-server prefers the live in-memory value; otherwise it reconstructs the latest surviving snapshot from rollout items using the same rollback/compaction semantics as core session replay. + - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Accepts the same permission override rules as `thread/start`. @@ -377,6 +379,8 @@ Later, after the idle unload timeout: Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +When present, `thread.epiphanyState` is the latest surviving persisted `EpiphanyThreadState` snapshot for that thread. + ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } { "id": 22, "result": { diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs index 525e75e6..fd3c199e 100644 --- a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs @@ -244,6 +244,7 @@ use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_name_by_id; use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; +use codex_core::latest_epiphany_state_from_rollout_items; use codex_core::path_utils; use codex_core::plugins::MarketplaceAddError; use codex_core::plugins::MarketplaceRemoveError; @@ -315,6 +316,7 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::ConversationStartTransport; use codex_protocol::protocol::ConversationTextParams; +use codex_protocol::protocol::EpiphanyThreadState; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::InitialHistory; @@ -2638,12 +2640,12 @@ impl CodexMessageProcessor { Ok(new_conv) => { let NewThread { thread_id, - thread, + thread: codex_thread, session_configured, .. } = new_conv; if let Err(error) = Self::set_app_server_client_info( - thread.as_ref(), + codex_thread.as_ref(), app_server_client_name, app_server_client_version, ) @@ -2655,7 +2657,7 @@ impl CodexMessageProcessor { .await; return; } - let config_snapshot = thread + let config_snapshot = codex_thread .config_snapshot() .instrument(tracing::info_span!( "app_server.thread_start.config_snapshot", @@ -2667,6 +2669,13 @@ impl CodexMessageProcessor { &config_snapshot, session_configured.rollout_path.clone(), ); + thread.epiphany_state = codex_thread + .epiphany_state() + .instrument(tracing::info_span!( + "app_server.thread_start.epiphany_state", + otel.name = "app_server.thread_start.epiphany_state", + )) + .await; // Auto-attach a thread listener when starting a thread. Self::log_listener_attach_result( @@ -3542,6 +3551,17 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); self.attach_thread_name(thread_id, &mut thread).await; + if let Some(rollout_path) = thread.path.as_deref() { + match load_epiphany_state_from_rollout_path(rollout_path).await { + Ok(epiphany_state) => { + thread.epiphany_state = epiphany_state; + } + Err(message) => { + self.send_internal_error(request_id, message).await; + return; + } + } + } let thread_id = thread.id.clone(); let response = ThreadUnarchiveResponse { thread }; self.outgoing.send_response(request_id, response).await; @@ -3993,6 +4013,9 @@ impl CodexMessageProcessor { ))); }; + self.apply_thread_read_epiphany_state(&mut thread, loaded_thread.as_ref()) + .await?; + let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { matches!(loaded_thread.agent_status().await, AgentStatus::Running) } else { @@ -4084,6 +4107,27 @@ impl CodexMessageProcessor { Ok(Some(thread)) } + async fn apply_thread_read_epiphany_state( + &self, + thread: &mut Thread, + loaded_thread: Option<&Arc>, + ) -> Result<(), ThreadReadViewError> { + if let Some(loaded_thread) = loaded_thread { + thread.epiphany_state = loaded_thread.epiphany_state().await; + return Ok(()); + } + + let Some(rollout_path) = thread.path.as_deref() else { + thread.epiphany_state = None; + return Ok(()); + }; + + thread.epiphany_state = load_epiphany_state_from_rollout_path(rollout_path) + .await + .map_err(ThreadReadViewError::Internal)?; + Ok(()) + } + async fn apply_thread_read_rollout_fields( &self, thread_id: ThreadId, @@ -4852,7 +4896,7 @@ impl CodexMessageProcessor { async fn load_thread_from_resume_source_or_send_internal( &self, thread_id: ThreadId, - thread: &CodexThread, + live_thread: &CodexThread, thread_history: &InitialHistory, rollout_path: &Path, fallback_provider: &str, @@ -4870,7 +4914,7 @@ impl CodexMessageProcessor { .await } InitialHistory::Forked(items) => { - let config_snapshot = thread.config_snapshot().await; + let config_snapshot = live_thread.config_snapshot().await; let mut thread = build_thread_from_snapshot( thread_id, &config_snapshot, @@ -4894,6 +4938,7 @@ impl CodexMessageProcessor { ) .await?; self.attach_thread_name(thread_id, &mut thread).await; + thread.epiphany_state = live_thread.epiphany_state().await; Ok(thread) } @@ -5165,6 +5210,8 @@ impl CodexMessageProcessor { return; } + thread.epiphany_state = forked_thread.epiphany_state().await; + self.thread_watch_manager .upsert_thread_silently(thread.clone()) .await; @@ -7508,6 +7555,7 @@ impl CodexMessageProcessor { match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { Ok(summary) => { let mut thread = summary_to_thread(summary, &self.config.cwd); + thread.epiphany_state = review_thread.epiphany_state().await; self.thread_watch_manager .upsert_thread_silently(thread.clone()) .await; @@ -9359,6 +9407,7 @@ fn thread_from_stored_thread( source: source.into(), git_info, name: thread.name, + epiphany_state: None, turns: Vec::new(), }; (thread, history) @@ -9842,6 +9891,7 @@ fn build_thread_from_snapshot( source: config_snapshot.session_source.clone().into(), git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), } } @@ -9897,10 +9947,25 @@ pub(crate) fn summary_to_thread( source: source.into(), git_info, name: None, + epiphany_state: None, turns: Vec::new(), } } +async fn load_epiphany_state_from_rollout_path( + rollout_path: &Path, +) -> std::result::Result, String> { + let items = read_rollout_items_from_rollout(rollout_path) + .await + .map_err(|err| { + format!( + "failed to load rollout `{}` for Epiphany state: {err}", + rollout_path.display() + ) + })?; + Ok(latest_epiphany_state_from_rollout_items(&items)) +} + fn thread_backwards_cursor_for_sort_key( summary: &ConversationSummary, sort_key: StoreThreadSortKey, @@ -10915,6 +10980,96 @@ mod tests { Ok(()) } + #[tokio::test] + async fn load_epiphany_state_from_rollout_path_reads_latest_snapshot() -> Result<()> { + use codex_protocol::protocol::EpiphanyStateItem; + use codex_protocol::protocol::EpiphanyThreadState; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; + use codex_protocol::protocol::UserMessageEvent; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + let epiphany_state = EpiphanyThreadState { + revision: 7, + objective: Some("Expose thread state to clients".to_string()), + active_subgoal_id: Some("phase-3".to_string()), + last_updated_turn_id: Some("turn-2".to_string()), + ..Default::default() + }; + + let rollout_lines = vec![ + RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }, + git: None, + }), + }, + RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + }, + RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "load the scene".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + }, + RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-2".to_string()), + state: epiphany_state.clone(), + }), + }, + RolloutLine { + timestamp, + item: RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + }, + ]; + + let encoded = rollout_lines + .iter() + .map(serde_json::to_string) + .collect::, _>>()? + .join("\n"); + fs::write(&path, format!("{encoded}\n"))?; + + let loaded = load_epiphany_state_from_rollout_path(path.as_path()) + .await + .map_err(anyhow::Error::msg)?; + assert_eq!(loaded, Some(epiphany_state)); + Ok(()) + } + #[tokio::test] async fn aborting_pending_request_clears_pending_state() -> Result<()> { let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; diff --git a/vendor/codex/codex-rs/app-server/src/thread_status.rs b/vendor/codex/codex-rs/app-server/src/thread_status.rs index f78b8753..a2f663d0 100644 --- a/vendor/codex/codex-rs/app-server/src/thread_status.rs +++ b/vendor/codex/codex-rs/app-server/src/thread_status.rs @@ -902,6 +902,7 @@ mod tests { source, git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), } } diff --git a/vendor/codex/codex-rs/core/src/codex_thread.rs b/vendor/codex/codex-rs/core/src/codex_thread.rs index cda2d22f..daa14b5a 100644 --- a/vendor/codex/codex-rs/core/src/codex_thread.rs +++ b/vendor/codex/codex-rs/core/src/codex_thread.rs @@ -20,6 +20,7 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EpiphanyThreadState; use codex_protocol::protocol::Event; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; @@ -315,6 +316,10 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + pub async fn epiphany_state(&self) -> Option { + self.codex.session.epiphany_state().await + } + pub async fn read_mcp_resource( &self, server: &str, diff --git a/vendor/codex/codex-rs/core/src/epiphany_rollout.rs b/vendor/codex/codex-rs/core/src/epiphany_rollout.rs new file mode 100644 index 00000000..c6e2a878 --- /dev/null +++ b/vendor/codex/codex-rs/core/src/epiphany_rollout.rs @@ -0,0 +1,318 @@ +use crate::context_manager::is_user_turn_boundary; +use codex_protocol::protocol::EpiphanyThreadState; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; + +#[derive(Debug, Default)] +struct ActiveEpiphanyReplaySegment { + turn_id: Option, + counts_as_user_turn: bool, + epiphany_state: Option, +} + +fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool { + active_turn_id + .is_none_or(|turn_id| item_turn_id.is_none_or(|item_turn_id| item_turn_id == turn_id)) +} + +fn finalize_active_segment( + active_segment: ActiveEpiphanyReplaySegment, + epiphany_state: &mut Option, + pending_rollback_turns: &mut usize, +) { + if *pending_rollback_turns > 0 { + if active_segment.counts_as_user_turn { + *pending_rollback_turns -= 1; + } + return; + } + + if epiphany_state.is_none() && active_segment.counts_as_user_turn { + *epiphany_state = active_segment.epiphany_state; + } +} + +pub fn latest_epiphany_state_from_rollout_items( + rollout_items: &[RolloutItem], +) -> Option { + let mut epiphany_state = None; + let mut pending_rollback_turns = 0usize; + let mut active_segment: Option = None; + + for item in rollout_items.iter().rev() { + match item { + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { + pending_rollback_turns = pending_rollback_turns + .saturating_add(usize::try_from(rollback.num_turns).unwrap_or(usize::MAX)); + } + RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { + let active_segment = + active_segment.get_or_insert_with(ActiveEpiphanyReplaySegment::default); + if active_segment.turn_id.is_none() { + active_segment.turn_id = Some(event.turn_id.clone()); + } + } + RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { + if let Some(active_segment) = active_segment.as_mut() { + if active_segment.turn_id.is_none() + && let Some(turn_id) = &event.turn_id + { + active_segment.turn_id = Some(turn_id.clone()); + } + } else if let Some(turn_id) = &event.turn_id { + active_segment = Some(ActiveEpiphanyReplaySegment { + turn_id: Some(turn_id.clone()), + ..Default::default() + }); + } + } + RolloutItem::EventMsg(EventMsg::UserMessage(_)) => { + let active_segment = + active_segment.get_or_insert_with(ActiveEpiphanyReplaySegment::default); + active_segment.counts_as_user_turn = true; + } + RolloutItem::ResponseItem(response_item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveEpiphanyReplaySegment::default); + active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); + } + RolloutItem::EpiphanyState(item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveEpiphanyReplaySegment::default); + if active_segment.turn_id.is_none() { + active_segment.turn_id = item.turn_id.clone(); + } + if turn_ids_are_compatible( + active_segment.turn_id.as_deref(), + item.turn_id.as_deref(), + ) && active_segment.epiphany_state.is_none() + { + active_segment.epiphany_state = Some(item.state.clone()); + } + } + RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { + if active_segment.as_ref().is_some_and(|active_segment| { + turn_ids_are_compatible( + active_segment.turn_id.as_deref(), + Some(event.turn_id.as_str()), + ) + }) && let Some(active_segment) = active_segment.take() + { + finalize_active_segment( + active_segment, + &mut epiphany_state, + &mut pending_rollback_turns, + ); + } + } + RolloutItem::EventMsg(_) + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) + | RolloutItem::SessionMeta(_) => {} + } + + if epiphany_state.is_some() { + break; + } + } + + if let Some(active_segment) = active_segment.take() { + finalize_active_segment( + active_segment, + &mut epiphany_state, + &mut pending_rollback_turns, + ); + } + + epiphany_state +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::CompactedItem; + use codex_protocol::protocol::EpiphanyStateItem; + use codex_protocol::protocol::ThreadRolledBackEvent; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; + use codex_protocol::protocol::UserMessageEvent; + + fn user_response_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + fn sample_epiphany_state(turn_id: &str) -> EpiphanyThreadState { + EpiphanyThreadState { + revision: 1, + objective: Some(format!("Objective for {turn_id}")), + active_subgoal_id: Some(format!("subgoal-{turn_id}")), + last_updated_turn_id: Some(turn_id.to_string()), + ..Default::default() + } + } + + #[test] + fn latest_epiphany_state_from_rollout_items_returns_latest_surviving_snapshot() { + let first = sample_epiphany_state("turn-1"); + let second = sample_epiphany_state("turn-2"); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "first".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-1".to_string()), + state: first, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_response_message("second")), + RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-2".to_string()), + state: second.clone(), + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]; + + assert_eq!( + latest_epiphany_state_from_rollout_items(&rollout_items), + Some(second) + ); + } + + #[test] + fn latest_epiphany_state_from_rollout_items_skips_rolled_back_turns() { + let first = sample_epiphany_state("turn-1"); + let second = sample_epiphany_state("turn-2"); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "first".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-1".to_string()), + state: first.clone(), + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "second".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-2".to_string()), + state: second, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + ]; + + assert_eq!( + latest_epiphany_state_from_rollout_items(&rollout_items), + Some(first) + ); + } + + #[test] + fn latest_epiphany_state_from_rollout_items_survives_compaction() { + let state = sample_epiphany_state("turn-before-compaction"); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-before-compaction".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "before compaction".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EpiphanyState(EpiphanyStateItem { + turn_id: Some("turn-before-compaction".to_string()), + state: state.clone(), + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-before-compaction".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + RolloutItem::Compacted(CompactedItem { + message: "compact".to_string(), + replacement_history: None, + }), + ]; + + assert_eq!( + latest_epiphany_state_from_rollout_items(&rollout_items), + Some(state) + ); + } +} diff --git a/vendor/codex/codex-rs/core/src/lib.rs b/vendor/codex/codex-rs/core/src/lib.rs index 54828795..0adf3397 100644 --- a/vendor/codex/codex-rs/core/src/lib.rs +++ b/vendor/codex/codex-rs/core/src/lib.rs @@ -17,9 +17,11 @@ pub(crate) mod session; pub use session::SteerInputError; mod codex_thread; mod compact_remote; +mod epiphany_rollout; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadTurnContextOverrides; pub use codex_thread::ThreadConfigSnapshot; +pub use epiphany_rollout::latest_epiphany_state_from_rollout_items; mod agent; mod codex_delegate; mod command_canonicalization; diff --git a/vendor/codex/codex-rs/exec/src/lib_tests.rs b/vendor/codex/codex-rs/exec/src/lib_tests.rs index f24c3fd5..85434ee0 100644 --- a/vendor/codex/codex-rs/exec/src/lib_tests.rs +++ b/vendor/codex/codex-rs/exec/src/lib_tests.rs @@ -259,6 +259,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: vec![ codex_app_server_protocol::Turn { id: "turn-1".to_string(), @@ -411,6 +412,7 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { agent_role: None, git_info: None, name: Some("thread".to_string()), + epiphany_state: None, turns: vec![], }, model: "gpt-5.4".to_string(), diff --git a/vendor/codex/codex-rs/tui/src/app/app_server_adapter.rs b/vendor/codex/codex-rs/tui/src/app/app_server_adapter.rs index f7793a55..c4ea5148 100644 --- a/vendor/codex/codex-rs/tui/src/app/app_server_adapter.rs +++ b/vendor/codex/codex-rs/tui/src/app/app_server_adapter.rs @@ -1327,6 +1327,7 @@ mod tests { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: vec![Turn { id: "turn-1".to_string(), items: vec![ThreadItem::CommandExecution { @@ -1503,6 +1504,7 @@ mod tests { agent_role: None, git_info: None, name: Some("restore".to_string()), + epiphany_state: None, turns: vec![ Turn { id: "turn-complete".to_string(), diff --git a/vendor/codex/codex-rs/tui/src/app/loaded_threads.rs b/vendor/codex/codex-rs/tui/src/app/loaded_threads.rs index b6653613..800725a1 100644 --- a/vendor/codex/codex-rs/tui/src/app/loaded_threads.rs +++ b/vendor/codex/codex-rs/tui/src/app/loaded_threads.rs @@ -127,6 +127,7 @@ mod tests { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), } } diff --git a/vendor/codex/codex-rs/tui/src/app/tests.rs b/vendor/codex/codex-rs/tui/src/app/tests.rs index 3cf6ff1a..25718f38 100644 --- a/vendor/codex/codex-rs/tui/src/app/tests.rs +++ b/vendor/codex/codex-rs/tui/src/app/tests.rs @@ -2728,6 +2728,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re agent_role: Some("explorer".to_string()), git_info: None, name: Some("agent thread".to_string()), + epiphany_state: None, turns: Vec::new(), }, }), @@ -2813,6 +2814,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ agent_role: Some("explorer".to_string()), git_info: None, name: Some("agent thread".to_string()), + epiphany_state: None, turns: Vec::new(), }, }), @@ -2871,6 +2873,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { agent_role: None, git_info: None, name: Some("read thread".to_string()), + epiphany_state: None, turns: Vec::new(), }; @@ -4633,6 +4636,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: Vec::new(), }, }, diff --git a/vendor/codex/codex-rs/tui/src/app_server_session.rs b/vendor/codex/codex-rs/tui/src/app_server_session.rs index b1f1e01c..7d1e5391 100644 --- a/vendor/codex/codex-rs/tui/src/app_server_session.rs +++ b/vendor/codex/codex-rs/tui/src/app_server_session.rs @@ -1576,6 +1576,7 @@ mod tests { agent_role: None, git_info: None, name: None, + epiphany_state: None, turns: vec![Turn { id: "turn-1".to_string(), items: vec![ diff --git a/vendor/codex/codex-rs/tui/src/resume_picker.rs b/vendor/codex/codex-rs/tui/src/resume_picker.rs index fe0825cc..e07b23ef 100644 --- a/vendor/codex/codex-rs/tui/src/resume_picker.rs +++ b/vendor/codex/codex-rs/tui/src/resume_picker.rs @@ -2048,6 +2048,7 @@ mod tests { agent_role: None, git_info: None, name: Some(String::from("Named thread")), + epiphany_state: None, turns: Vec::new(), };