diff --git a/.claude/skills/release-freshell/SKILL.md b/.claude/skills/release-freshell/SKILL.md index b6a9bda30..46a9d739d 100644 --- a/.claude/skills/release-freshell/SKILL.md +++ b/.claude/skills/release-freshell/SKILL.md @@ -94,13 +94,14 @@ Present the proposed README changes to the user for approval before proceeding t ## Release Steps -All work happens on a release branch in a worktree — main is untouched until the final atomic fast-forward. This protects the running Freshell instance. +All release preparation happens on a release branch in a worktree from `origin/main`. Local `main` is a mirror of `origin/main`; do not commit to it, fast-forward it, or push it directly. ### 1. Create the release branch ```bash -# From the main repo -git worktree add .worktrees/release-vX.Y.Z -b release/vX.Y.Z main +# From the repo root +git fetch origin +git worktree add .worktrees/release-vX.Y.Z -b release/vX.Y.Z origin/main cd .worktrees/release-vX.Y.Z npm install ``` @@ -129,19 +130,14 @@ All of these are committed to the release branch: 2. **Update README:** Change `--branch vOLD` to `--branch vNEW` in the clone command, and apply the approved Features changes 3. **Commit** with message like `release: vX.Y.Z` -### 4. Fast-forward main +### 4. Open and merge a release PR -```bash -# Back in the main repo working directory -git merge --ff-only release/vX.Y.Z -``` - -If `--ff-only` fails, go back to the worktree and rebase onto main until it can fast-forward. +Push the release branch and open a PR against `main`. After user approval and required checks, merge it through GitHub. If conflicts appear, rebase the release branch onto `origin/main`, resolve in the worktree, retest, and force-push with `--force-with-lease`. ### 5. Tag and publish ```bash -git push origin main +git fetch origin git tag -a vX.Y.Z -m "vX.Y.Z" git push --tags gh release create vX.Y.Z --title "vX.Y.Z" --notes "..." # with the release notes @@ -156,8 +152,8 @@ git branch -d release/vX.Y.Z ## Safety -- Main can contain work-in-progress; users clone a specific release tag -- You are running inside Freshell — if you break main mid-release, you kill yourself -- All release prep happens on a branch in a worktree, so main is never modified until the atomic fast-forward +- Local `main` mirrors `origin/main`; release work happens on a PR branch. +- Users clone a specific release tag. +- The self-hosted integration branch is `dev`, not local `main`. - Commit the version bump before tagging so the tag points to the right commit - If any step fails, stop and assess, then make recommendations to the user, rather than pushing forward diff --git a/.claude/skills/reviewing-prs/SKILL.md b/.claude/skills/reviewing-prs/SKILL.md index 4d7b8ad4e..3f337a85f 100644 --- a/.claude/skills/reviewing-prs/SKILL.md +++ b/.claude/skills/reviewing-prs/SKILL.md @@ -17,7 +17,7 @@ We never push work back onto contributors. Our goal is to harvest **good ideas f 1. Create a detached worktree for all PR work: ```bash - git worktree add .worktrees/pr-review --detach HEAD + git worktree add .worktrees/pr-review --detach origin/main ``` 2. List open PRs with `gh pr list` 3. Process oldest-to-newest (reduces cascading merge conflicts) @@ -28,11 +28,11 @@ We never push work back onto contributors. Our goal is to harvest **good ideas f ```bash cd .worktrees/pr-review -git fetch origin && git checkout --detach origin/main +git fetch origin && git switch --detach origin/main gh pr checkout ``` -Always reset the worktree to latest main before each PR. +Always reset the worktree to latest `origin/main` before each PR. ### 2. Review the Diff @@ -85,7 +85,7 @@ Present build, test, and fresheyes results to the user. Include: Address all fresheyes findings and test failures before merging: - Commit fixes on the PR branch with detailed messages -- **Show the diff and get approval before pushing** to the PR branch or main +- **Show the diff and get approval before pushing** to the PR branch - Re-build and re-test after fixes ### 7. Merge (only after user approves) @@ -96,14 +96,9 @@ gh pr merge --merge ``` **If GitHub's merge state is stale** (common after rebase/force-push): -```bash -# In the main repo (not the worktree) -git fetch origin && git merge --ff-only origin/main -git merge --no-ff -m "Merge pull request #N from ..." -git push origin main -``` +refresh the PR branch on `origin/main`, force-push it with `--force-with-lease`, and use GitHub merge after the merge state updates. Do not merge locally into `main` or push `main` directly. -**If conflicts exist:** rebase the PR branch on main, resolve, rebuild, retest, force-push, then merge. +**If conflicts exist:** rebase the PR branch on `origin/main`, resolve, rebuild, retest, force-push with `--force-with-lease`, then merge through GitHub. ### 8. Comment and Close @@ -116,7 +111,7 @@ Leave an effusive comment summarizing: gh pr comment --body "..." ``` -Then ensure the PR is closed. Local merges (the fallback path) don't trigger GitHub's auto-close: +Then ensure the PR is closed: ```bash # Check if still open; close if needed @@ -128,10 +123,6 @@ gh pr view --json state -q '.state' | grep -q OPEN && gh pr close Once all PRs are landed and there is no unfinished work, clean up: ```bash -# Switch back to main in the primary repo -cd -git checkout main && git pull --ff-only origin main - # Remove the pr-review worktree git worktree remove .worktrees/pr-review ``` @@ -192,7 +183,7 @@ The user may respond with: ##### c. Fix (if approved) -- Create a branch: `git checkout -b fix/issue- origin/main` +- Create a branch in a worktree from `origin/main`. - Implement the fix in the worktree - Build and run targeted tests - **Show the diff and get approval before pushing** @@ -219,7 +210,7 @@ Always sign the comment `— Codex CLI`. | Tests | `go test ./...` (targeted) | — | | Present results | Build/test/fresheyes summary | **User approval (Gate 2)** | | Fix | Commit + show diff + get approval before push | Build + tests green | -| Merge | `gh pr merge` or local merge | All fixes landed + user says merge | +| Merge | `gh pr merge` | All fixes landed + user says merge | | Comment + Close | `gh pr comment` + `gh pr close` if still open | Merge complete | | Reset | `git checkout --detach origin/main` | Before next PR | | Teardown | `git worktree remove .worktrees/pr-review` | Queue empty, all landed | @@ -248,10 +239,10 @@ If you catch yourself thinking any of these, you are about to violate a gate: | Proceeding without user approval | There are TWO hard gates. Always wait at both. | | Treating ambiguous signals as approval | Only explicit approval words count. When in doubt, ask. | | Merging after build+fresheyes without asking | Gate 2 requires presenting results and waiting for go/no-go. | -| Pushing fixes to main without showing diff | Show the diff first. Get approval before any push. | +| Pushing fixes without showing diff | Show the diff first. Get approval before any push to the PR branch. | | Merging with fresheyes FAILED | Fix all findings first. Never merge a failed review. | | Forgetting to update worktree between PRs | Stale base causes unnecessary conflicts. Always reset to origin/main. | -| Forgetting to close after local merge | Local merges don't trigger GitHub auto-close. Always check and `gh pr close` if still open. | +| Pushing directly to `main` | `main` mirrors `origin/main`. Use PR branches and GitHub merge. | | Signing comment as "Codex" or "Claude" | Always sign as `— Codex CLI`. | | Processing PRs newest-first | Oldest-first minimizes cascading conflicts since earlier PRs often touch files later ones depend on. | | Processing issues oldest-first | Issue triage is newest-first so the most recent reports and follow-ups are evaluated first. | diff --git a/AGENTS.md b/AGENTS.md index d26f34bf4..d600501a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o ## Repo Rules - Always work in a worktree (in \.worktrees\) +- New behavior changes start on a worktree branch from `origin/main` and are submitted as PRs to `origin/main`; local `dev` only consumes PR heads. - Many agents may be working in the worktree at the same time. If you see activity from other agents (for example test runs or file changes), respect it. - Specific user instructions override ALL other instructions, including the above, and including superpowers or skills - Server uses NodeNext/ESM; relative imports must include `.js` extensions @@ -26,16 +27,19 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o - Use `npm run test:vitest -- ...` for a repo-owned direct Vitest path. Raw `npx vitest` is not a coordinated workflow. - `test:unit` is the exact default-config `test/unit` workload, `test:integration` is the exact server-config `test/server` workload, and `test:server` stays watch-capable unless you pass an explicit broad `--run`. -## Merging to Main (CRITICAL - Read This) +## Branch Model And Self-Hosting (CRITICAL - Read This) -**You are running inside Freshell right now. This session, the terminal the user is typing in, is served by the main branch. If you break main, you kill yourself mid-operation and the user has to clean up your mess with a separate agent.** +**This checkout is self-hosted from local `dev`, not local `main`.** -- Never run `git merge` directly on main - merge conflicts write `<<<<<<< HEAD` markers into source files, which crashes the server instantly -- Always merge main INTO the feature branch in the worktree first, resolve any conflicts there -- Before fast-forwarding main, run `npm test` and confirm all tests pass (not just related tests) -- If you find failing tests, you must stop everything to understand them and make improvements, even if you do not think you were responsible for them. -- Then fast-forward main: `git merge --ff-only feature/branch` - this is atomic (pointer move, no intermediate states) -- If `--ff-only` fails, go back to the worktree and rebase/merge until it can fast-forward +- Local `main` is a mirror of `origin/main`; do not commit to it, merge into it, or self-host from it. +- Local `dev` is the self-hosted integration branch. It is rebuilt from `origin/main` plus pending PR heads. +- The repo root normally remains on local `main`; use `.worktrees/dev` as the self-hosted integration checkout and create separate worktrees for authored changes. +- Do not edit production behavior directly on `dev`. +- If a change is needed on `dev`, create or update a PR against `origin/main`, then apply that PR head to `dev`. +- If applying a PR to `dev` needs semantic conflict resolution, stop and fix the PR branch or create a replacement PR. Do not hide behavior changes in a local-only `dev` merge commit. +- Never run `git merge` directly on `main`. +- Never reset, force-push, or fast-forward local `main` during ordinary work. If the user explicitly asks to refresh the mirror, first verify Freshell is self-hosting from `dev` and local `main` has no work to preserve. +- We cannot approve our own PRs. `dev` may contain unapproved pending work, but `origin/main` changes still require independent review. ## Process Safety (CRITICAL) @@ -46,6 +50,7 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o - **NEVER run `node dist/server/index.js` directly** — use `npm start` which sets `NODE_ENV=production`; without it the server prints the Vite port (5173) in the startup URL even though Vite isn't running - Example stop: `kill "$(cat /tmp/freshell-3344.pid)" && rm -f /tmp/freshell-3344.pid` - Before stopping any process, verify it belongs to the worktree (`ps -fp ` and confirm cwd/path includes `.worktrees/...`). +- **The self-hosted dev server must never be restarted without explicit user approval (the word "APPROVED").** Building is fine; deploying (stop + start) is not. The user's current Freshell session depends on it, and an unapproved restart will disconnect them mid-operation. ## Codex Agent in CMD Instructions (Codex agents only; only when running in CMD on windows; all other agents must ignore) - Prefer bash/WSL over PowerShell; Windows paths map like `D:\...` -> `/mnt/d/...`. diff --git a/README.md b/README.md index 593830b76..c9431ea8f 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Drop a directory with a `freshell.json` manifest into `~/.freshell/extensions/` ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome. Start from `origin/main`, submit a Pull Request against `main`, and keep behavior changes on PR branches. Local development self-hosting uses the `dev` integration branch described in [docs/development/branch-model.md](docs/development/branch-model.md). ## Community Projects diff --git a/docs/debugging/session-observability.md b/docs/debugging/session-observability.md index 6f720127d..2eaeaf449 100644 --- a/docs/debugging/session-observability.md +++ b/docs/debugging/session-observability.md @@ -58,6 +58,29 @@ If `invalid_terminal_id_without_session_ref` appears after `terminal_created` wi If `client_restore_unavailable` appears, use its `tabId`, `paneId`, `terminalId`, and `connectionId` to join the UI failure back to websocket stale-terminal events and terminal lifecycle events. +## Provider Session Binding + +Freshell logs provider durable-session binding with terminal id, provider, durable session id, source, and rejection reason. These logs intentionally exclude terminal input, auth tokens, process environments, full child command lines, raw stderr/stdout, raw websocket error text, and provider database absolute paths. + +Important events: + +- `ws_send_error`: a websocket error was sent to a client. Repeated equivalent errors are summarized, not discarded. +- `ws_send_error_suppressed_summary`: repeated websocket errors were suppressed during a bounded window; includes counts and sampled request ids. +- `session_association_broadcast`: a provider durable session id was broadcast to clients. +- `restore_unavailable`: a restore request lacked durable identity and was rejected before creating a process. +- `restore_unavailable_fresh_fallback`: the client explicitly requested a fresh terminal after restore was unavailable. +- `OpenCode session associated; scheduled provider refresh`: OpenCode binding should be followed by a session-directory refresh. + +OpenCode database paths are logged as sanitized labels such as `/opencode.db`, never absolute user paths. Missing, empty, unavailable SQLite, schema, and read failures are separate message classes so a missing database is not mistaken for a healthy empty session list. + +## OpenCode Fresh Session Binding + +Freshell does not preallocate caller-provided `--session` ids for fresh OpenCode terminals in this PR. The durable identity for a fresh OpenCode process comes from OpenCode after launch, and Freshell binds the first unambiguous root session immediately, even while the turn is still busy. + +Source/profile evidence for OpenCode 1.14.48 shows `--session ` resumes an existing session and hard-fails when the id is missing. Do not invent caller-provided fresh session ids. + +The only plausible session-at-birth architecture is to pre-create a session through OpenCode's own server API, then launch the TUI with `--session `. That is explicitly out of scope for this PR because it changes process startup ownership. This PR relies on provider-confirmed early binding from the terminal's own OpenCode endpoint and SQLite root mapping. + ## Data Policy The lifecycle log may include terminal ids, request ids, connection ids, tab ids, pane ids, providers, durable session ids, process ids, exit codes, and cwd. It must not include terminal input data, auth tokens, process environments, or full command-line arguments. diff --git a/docs/development/branch-model.md b/docs/development/branch-model.md new file mode 100644 index 000000000..b93832c71 --- /dev/null +++ b/docs/development/branch-model.md @@ -0,0 +1,104 @@ +# Branch Model + +Freshell development uses two local integration concepts: + +- `main`: exact mirror of `origin/main` +- `dev`: self-hosted local integration branch + +## Branch Responsibilities + +`main` is disposable. It should always be resettable to `origin/main` with no local work lost. + +`dev` is where the local Freshell instance runs. It is assembled from `origin/main` plus pending PR heads. It is not where new behavior is authored. + +## Pending PR Definition + +A PR is pending for `dev` only when all of these are true: + +- It is open. +- It targets `main`. +- It is not draft. +- It is not marked do-not-merge, superseded, or approval-artifact-only. +- The user wants it in the self-hosted integration queue. +- Its branch applies cleanly to `origin/main`, or its branch has been updated so it does. + +If a PR cannot be amended because it comes from an external fork, create a replacement PR before adding that behavior to `dev`. + +## Change Flow + +1. Start work from `origin/main` in a worktree. +2. Implement the change. +3. Push a PR against `origin/main`. +4. Add that PR head to local `dev`. +5. Wait for independent review before merging the PR to `origin/main`. + +Never put behavior changes only on `dev`. + +## Conflict Policy + +If a PR conflicts with `origin/main`, fix the PR branch. + +If two pending PRs conflict with each other, fix one or both PR branches. + +Do not resolve semantic conflicts only on `dev`. `dev` must remain reproducible from `origin/main` plus PR heads. + +## Excluded PRs + +Draft PRs, do-not-merge PRs, closed PRs, superseded PRs, and approval artifacts are excluded from `dev` unless the user explicitly says otherwise. + +## Building `dev` + +Use an explicit queue. Do not blindly apply every open PR. + +Example: + +```bash +npm run dev:queue -- plan --prs 323,321,309,319,324,326,325,322 +``` + +The queue script must fail if a PR is draft, closed, not targeting `main`, or cannot be applied cleanly. Fix PR branches before rebuilding `dev`. + +To rebuild local `dev`: + +```bash +git switch dev +npm run dev:queue -- assemble --prs 323,321,309,319,324,326,325,322 +``` + +Use replacement PR numbers instead of external or superseded PRs. If the script stops on a conflict, do not resolve the conflict on `dev`. Abort the merge, fix the PR branch, and rerun the queue. + +Current `dev` queue snapshot: + +Refresh this list before rebuilding `dev`; PR heads may move as branches are amended. + +| PR | Head SHA | Purpose | +| --- | --- | --- | +| #323 | Current PR head | `dev` branch workflow, launch guardrails, and queue tooling | +| #321 | `7eae9acf13d2ecf36de6ecade8354cb22b944f7b` | Sidebar reopen corner behavior | +| #309 | `93c0e15f8b3e04d7e1bbd8ab312619ae28cfefa2` | Codex startup cwd fix | +| #319 | `48927eef6b46a2232ebe31d1e1dea38d2203eb72` | OpenCode native scroll behavior | +| #324 | `fc8a953565c8c4e416fc7bc0e951b0888c8ed421` | Durable session restore identity parity | +| #326 | Current PR head | Codex sidecar resilience parity | +| #325 | Current PR head | Intentional removal of broken Codex notification launch args | +| #322 | Current PR head | Replacement for externally-owned factory terminal orchestration PR | + +Current queue exclusions: + +| PR | Head SHA | Reason | +| --- | --- | --- | +| #297 | `8cad328c158a6b33d9779ce1748bfe725ecd0d1c` | Externally-owned and superseded by #322 | +| #289 | `4e4782699adadc3e006b96143f6ead6bda8b136d` | Draft approval artifact | + +## Local Main Mirror + +Local `main` is a read-only mirror of `origin/main`. It should contain no local-only work and should not host the running Freshell server. + +Do not commit to, merge into, or fast-forward local `main` during ordinary development. If the user explicitly asks to refresh the mirror, first verify Freshell is self-hosting from local `dev`, then use: + +```bash +git switch main +git fetch origin +git reset --hard origin/main +``` + +Only run this when preserving local `main` history is not required. diff --git a/docs/index.html b/docs/index.html index 90206eb5a..68a6ca177 100644 --- a/docs/index.html +++ b/docs/index.html @@ -97,6 +97,9 @@ background: hsl(var(--background)); flex-shrink: 0; position: relative; z-index: 20; transition: background-color .2s; gap: 2px; } +.tabbar.multirow { + height: auto; max-height: 128px; flex-wrap: wrap; overflow-y: auto; +} .tabbar::after { content: ''; position: absolute; inset: auto 0 0 0; height: 1px; background: hsl(var(--muted-foreground) / .45); z-index: 0; diff --git a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md index 34123ed2b..2f089d6ff 100644 --- a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md +++ b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md @@ -1,6 +1,6 @@ # Coding CLI Session Contract Lab Note -This note records the real-binary provider probes rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`. +This note records the real-binary provider probes rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-14` inside `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514`; the OpenCode binary version fact was refreshed on `2026-05-16` inside `/home/user/code/freshell/.worktrees/dev-green-20260516`. The implementation plan file is dated `2026-04-19` because the design work was written the day before. This note is dated `2026-04-26` because the real-provider contracts were re-proved on the implementation machine on that date, and that verification date is the one Freshell is allowed to build on. @@ -9,7 +9,7 @@ The implementation plan file is dated `2026-04-19` because the design work was w { "capturedOn": "2026-04-26", "planCreatedOn": "2026-04-19", - "dateReason": "The plan was drafted on 2026-04-19, but the checked-in note is dated 2026-04-26 because that is when the durable behavior contract was re-proved on the implementation machine and the earlier 2026-04-23 contract capture was superseded by the newer provider behavior. Binary version facts were refreshed on 2026-05-03 after the installed provider versions changed.", + "dateReason": "The plan was drafted on 2026-04-19, but the checked-in note is dated 2026-04-26 because that is when the durable behavior contract was re-proved on the implementation machine and the earlier 2026-04-23 contract capture was superseded by the newer provider behavior. Binary version facts were refreshed on 2026-05-14 after the installed provider versions changed, and the OpenCode binary version fact was refreshed on 2026-05-16 after the installed provider version changed again.", "cleanup": { "liveProcessAuditCommand": "ps -eo pid,ppid,stat,cmd --sort=pid | rg \"codex|claude|opencode\"", "ownershipReportFields": [ @@ -37,7 +37,7 @@ The implementation plan file is dated `2026-04-19` because the design work was w "codex": { "executable": "codex", "resolvedPath": "/home/user/.npm-global/bin/codex", - "version": "codex-cli 0.128.0", + "version": "codex-cli 0.130.0", "freshRemoteBootstrapCommand": "codex --remote ", "freshRemoteBootstrapEventsBeforeUserTurn": [ "connection", @@ -60,8 +60,11 @@ The implementation plan file is dated `2026-04-19` because the design work was w ], "remoteResumeBootstrapFollowupMethods": [ "account/rateLimits/read", + "command/exec", + "hooks/list", "skills/list", - "skills/list" + "skills/list", + "thread/goal/get" ], "freshRemoteAllocatesThreadBeforeUserTurn": true, "shellSnapshotGlob": ".codex/shell_snapshots/*.sh", @@ -81,7 +84,7 @@ The implementation plan file is dated `2026-04-19` because the design work was w "executable": "claude", "resolvedPath": "/home/user/bin/claude", "isolatedBinaryPath": "/home/user/.local/bin/claude", - "version": "2.1.126 (Claude Code)", + "version": "2.1.140 (Claude Code)", "exactIdCommandTemplate": "HOME= /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --session-id ", "namedResumeCommandTemplate": "HOME= /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --resume [--name ] <prompt>", "transcriptGlob": ".claude/projects/*/<uuid>.jsonl", @@ -94,7 +97,7 @@ The implementation plan file is dated `2026-04-19` because the design work was w "opencode": { "executable": "opencode", "resolvedPath": "/home/user/.opencode/bin/opencode", - "version": "1.14.33", + "version": "1.15.3", "runCommandTemplate": "opencode run <prompt> --format json --dangerously-skip-permissions", "serveCommandTemplate": "opencode serve --hostname 127.0.0.1 --port <port>", "globalHealthPath": "/global/health", @@ -102,6 +105,7 @@ The implementation plan file is dated `2026-04-19` because the design work was w "canonicalIdentity": "session-id", "runEventSessionIdMatchesDbId": true, "busyStatusUsesAuthoritativeSessionId": true, + "attachFormatJsonEmitsEvents": true, "titleOnResumeMutatesStoredTitle": false, "sessionSubcommands": [ "list", @@ -138,10 +142,10 @@ command -v codex # /home/user/.npm-global/bin/codex codex --version -# codex-cli 0.128.0 +# codex-cli 0.130.0 ``` -This 2026-05-03 version refresh supersedes the older `codex-cli 0.125.0` capture. The current version of record on this machine is `codex-cli 0.128.0`. +This 2026-05-14 version refresh supersedes the older `codex-cli 0.128.0` capture. The current version of record on this machine is `codex-cli 0.130.0`. Fresh remote bootstrap was probed with a loopback websocket stub and: @@ -160,7 +164,7 @@ Before any user turn, the CLI opened a connection and issued: That proves fresh `codex --remote` allocates a thread during bootstrap, before the first user turn, but that thread allocation is not yet the durable contract Freshell may persist. -The remote resume form was re-proved through a websocket proxy in front of the real app-server. Before any user turn, `codex --remote <ws> --no-alt-screen resume <sessionId>` issued the stable prefix through `thread/resume`, and then the follow-up `skills/list` and `account/rateLimits/read` calls. The trailing post-resume follow-up order was observed to vary between reruns on the same binary, so only the stable prefix plus the required follow-up method set is treated as contract. +The remote resume form was re-proved through a websocket proxy in front of the real app-server. Before any user turn, `codex --remote <ws> --no-alt-screen resume <sessionId>` issued the stable prefix through `thread/resume`, and then the follow-up `account/rateLimits/read`, `command/exec`, `hooks/list`, `skills/list`, and `thread/goal/get` calls. The trailing post-resume follow-up order was observed to vary between reruns on the same binary, so only the stable prefix plus the required follow-up method set is treated as contract. Real provider-owned durability was re-proved against the app-server websocket with: @@ -229,6 +233,19 @@ Allowed Freshell behavior: - Freshell may only persist canonical Codex identity after the durable `.jsonl` artifact exists at the provider-reported `thread.path`. - Freshell must not treat the bootstrap `thread/start` id as durable restore identity. +### 2026-05-14 Codex restore decision addendum + +The `da2e0076` refactor added a design constraint that belongs with the provider contract: deterministic Codex restore needs one typed create/restore decision path, not only a correct rollout proof reader. Restore-like entry points must make the same decision about canonical `sessionRef`, captured candidate proof, live attach after proof failure, fresh create, and legacy raw resume. Keeping those choices local to each caller risks separate restore semantics. + +Design-level change recorded from `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514`: `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/server/coding-cli/codex-app-server/restore-decision.ts` now owns `planCodexCreateRestoreDecision` and `resolveCodexCreateRestoreDecision`, and `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/server/ws-handler.ts` routes Codex `terminal.create` and reopen handling through it. This is a narrow centralization, not a claim that every surface is done. + +Follow-up constraints: + +- Move exact live-candidate matching into the central module or make its typed input contract require enough live candidate identity for the module to verify `candidateThreadId` and `rolloutPath`. +- Remove or replace `legacy_raw_resume_passthrough`; raw resume should not remain a durable restore identity path. +- Extend the same decision path to REST, MCP, CLI, and any future restore-like surface instead of maintaining parallel semantics. +- Add surface matrix tests so coverage proves all entry points use the same restore decisions, not just the decision module and the current websocket route. + ## Claude Version and binaries: @@ -238,7 +255,7 @@ command -v claude # /home/user/bin/claude claude --version -# 2.1.126 (Claude Code) +# 2.1.140 (Claude Code) ``` The wrapper at `/home/user/bin/claude` shells out to `/home/user/.local/bin/claude`. The isolated probes used the actual binary and overrode `HOME` to keep persistence inside the probe temp root. @@ -287,7 +304,7 @@ command -v opencode # /home/user/.opencode/bin/opencode opencode --version -# 1.14.33 +# 1.15.3 ``` Fresh isolated runs were probed with: @@ -312,9 +329,10 @@ curl http://127.0.0.1:<port>/session/status Observed control behavior: -- `/global/health` returned a healthy payload with version `1.14.33`. +- `/global/health` returned a healthy payload with version `1.15.3`. - `/session/status` returned `{}` while idle. -- During an attached `opencode run ... --attach http://127.0.0.1:<port>`, `/session/status` returned the same authoritative `sessionID` with `{ "type": "busy" }`. +- During an attached `opencode run ... --attach http://127.0.0.1:<port>`, `/session/status` returned an authoritative `sessionID` with `{ "type": "busy" }`, and the same id was persisted as a `session.id` row in the isolated OpenCode database. +- On OpenCode `1.15.3`, attached `opencode run ... --attach ... --format json` exited successfully and emitted JSON event lines on stdout. `/session/status` remains the authoritative live-control surface, and the attached-run stdout session id matched the busy status/database session id in this probe. Title semantics were probed with: diff --git a/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md new file mode 100644 index 000000000..d2ebc30e1 --- /dev/null +++ b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md @@ -0,0 +1,750 @@ +# Coding CLI Session Restore Research + +This is the primary research record for how Freshell should identify, persist, and restore sessions for Codex, Claude Code, and OpenCode. Consult this file before changing session identity, restore, resume, sidebar, or terminal recovery behavior. + +## What matters + +| Provider | Deterministic restore identity | What works | What fails or must not be used | Not fully studied | +| --- | --- | --- | --- | --- | +| Codex | The rollout-backed root TUI `ThreadId` after the exact provider-reported `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` file exists and starts with matching `session_meta`. | Fresh `codex --remote` creates a thread before user work; Freshell can capture that pre-durable candidate after installing listeners, then promote only after the exact rollout file proves the same root TUI `ThreadId`. `turn/completed` is the required proof-check boundary, not proof itself. | Pre-creating an app-server thread and launching the TUI with `codex resume <threadId>` before the rollout file exists fails with `no rollout found for thread id`. Cwd, time, title, shell snapshot, and bare pre-durable thread id are not durable restore identity. If proof fails after `turn/completed`, Freshell must show a degraded/error state and use only deterministic one-shot repair triggers. | Full long-idle and restart behavior still needs product-level coverage, but the identity contract is known. | +| Claude Code | The UUID-backed transcript file under `.claude/projects/*/<uuid>.jsonl`. | `--session-id <uuid>` creates a durable transcript, and `--resume <uuid>` restores it. | Titles and names are mutable metadata only. The old title stops resolving after rename. | The proof covers print-mode session creation/resume/rename; broader interactive TUI edge cases are not the source of truth here. | +| OpenCode | The authoritative `sessionID` from JSON events, the DB row, and `/session/status`. | JSON `step_start` session id matches the DB session id; `/session/status` reports the same busy id while attached. | Titles are metadata and do not replace session identity. No rename subcommand was present in the tested mode. | Full interactive TUI restart and long-idle behavior still needs product-level coverage. | + +## Freshell rules + +- Never infer a coding-agent restore identity from cwd, launch time, tab title, pane title, or proximity. +- For Codex, capture the pre-durable root TUI `ThreadId` candidate before allowing user input, but persist it as a candidate only; promote it to canonical durable identity only after the exact rollout path returned by Codex exists and starts with parseable `session_meta` whose `payload.id` matches the candidate `ThreadId`. +- For Codex, `turn/completed` is the mandatory proof-check boundary. It is not itself proof of durable restore. On that event, Freshell must run one exact proof read and either promote to durable or mark `durability_unproven_after_completion`. +- For Codex, a post-completion proof failure is not a normal grey/live-only steady state. Later Codex events, `fs/changed`, PTY exit, app-server websocket close/error, and user restore/list/open actions may each trigger one exact repair proof read, but Freshell must not start periodic or backoff read loops. +- For Codex, do not try to prevent restore loss by pre-creating an app-server thread and TUI-resuming it before rollout materialization; the real binary rejected that path. +- For Claude Code, persist the UUID transcript identity, not the visible title or `--name` value. +- For OpenCode, promote only from authoritative provider surfaces: JSON events, the DB/session row, or `/session/status`. +- Cleanup for probes must never stop real user sessions; only processes tagged with the current temp root and sentinel are safe to stop. + +## Implementation learnings from dev integration + +The `2026-05-15` squash integration into `/home/user/code/freshell/.worktrees/dev` showed that provider identity proof is only one part of session resilience. The client also has existing repair and indexing behavior that must be preserved when adding stricter Codex durability state. + +- Sidebar rows must still be built from all known live coding terminals, including live-only terminals that do not yet have a durable provider identity. Without that, newly created Codex, OpenCode, or Claude Code terminals can fail to appear in the left pane until a later provider event creates a different row. +- Open-tab fallback timestamps must keep using the minute-bucketed pane activity path. Falling back only to tab creation time makes active live sessions look stale or grey after user input, which is a UI state bug even when the underlying terminal is healthy. +- When a tab has a durable tab-level `sessionRef` but its single terminal pane is an old live handle with no pane-level durable locator, opening that session from the sidebar must inject the tab-level `sessionRef` into the pane before trying to reuse it. This is deterministic repair from already-persisted state, not cwd/time matching. +- If a live-only terminal handle disappears and no durable identity can be restored, the ergonomic recovery path is one explicit fresh terminal creation. That is still a restore failure, so it must emit the restore-unavailable diagnostic and avoid pretending the old session was recovered. +- Agent-chat visible timeline hydration must prefer the persisted resume identity over the transient SDK handle after reload. Otherwise the UI can fetch history for the live SDK session id instead of the durable Claude transcript id. +- Conflict resolution can silently remove these behaviors even when the new Codex durability tests pass. Future changes to session identity, sidebar rows, terminal recovery, or agent-chat reloads should run tests that cover both the new provider-specific contract and the older client repair paths. + +## Scope and provenance + +The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. + +The later version-only refreshes did not re-prove the full behavior contract, so `capturedOn` remains `2026-04-26`; the `2026-05-13` experiment is recorded as a narrow Codex addendum. A Codex source-code study was added on `2026-05-13` against the locally installed `@openai/codex` package and the official upstream `openai/codex` tag `rust-v0.130.0`. + +The implementation plan file is dated `2026-04-19` because the design work was written the day before. This research record is dated `2026-05-13` because it now includes the targeted Codex pre-durable resume experiment. The durable behavior contract date remains `2026-04-26`, because that is when the full real-provider contract was re-proved on the implementation machine and that verification date is the one Freshell is allowed to build on. + +The real-provider harness parses the next section. Keep the `## Machine-readable contract` heading and the fenced JSON block intact when editing this file. + +## Machine-readable contract +```json +{ + "capturedOn": "2026-04-26", + "planCreatedOn": "2026-04-19", + "binaryVersionFactsRefreshedOn": "2026-05-06", + "dateReason": "The plan was drafted on 2026-04-19, but the checked-in note is dated 2026-04-26 because that is when the durable behavior contract was re-proved on the implementation machine and the earlier 2026-04-23 contract capture was superseded by the newer provider behavior. Binary version facts were refreshed on 2026-05-03 after installed provider versions changed, and the Claude Code binary version fact was refreshed on 2026-05-06 after the local installed binary changed to 2.1.132. These later version-only refreshes did not re-prove the behavior contract.", + "cleanup": { + "liveProcessAuditCommand": "ps -eo pid,ppid,stat,cmd --sort=pid | rg \"codex|claude|opencode\"", + "ownershipReportFields": [ + "pid", + "ppid", + "cwd", + "tempHome", + "sentinelPath", + "safeToStop", + "command" + ], + "safeToStopRequires": [ + "FRESHELL_PROBE_HOME must match the current temp root.", + "FRESHELL_PROBE_SENTINEL must match the current sentinel path." + ], + "safeExamples": [ + "Probe-owned temp-home root processes and their descendants tagged by the current harness sentinel." + ], + "unsafeExamples": [ + "Real user codex, claude, or opencode sessions under the user home.", + "Any process that lacks the current harness sentinel metadata." + ] + }, + "providers": { + "codex": { + "executable": "codex", + "resolvedPath": "/home/user/.npm-global/bin/codex", + "version": "codex-cli 0.130.0", + "freshRemoteBootstrapCommand": "codex --remote <ws>", + "freshRemoteBootstrapEventsBeforeUserTurn": [ + "connection", + "initialize", + "initialized", + "account/read", + "account/read", + "model/list", + "thread/start" + ], + "remoteResumeBootstrapStablePrefix": [ + "connection", + "initialize", + "initialized", + "account/read", + "thread/read", + "account/read", + "model/list", + "thread/resume" + ], + "remoteResumeBootstrapFollowupMethods": [ + "account/rateLimits/read", + "command/exec", + "hooks/list", + "skills/list", + "skills/list", + "thread/goal/get" + ], + "freshRemoteAllocatesThreadBeforeUserTurn": true, + "shellSnapshotGlob": ".codex/shell_snapshots/*.sh", + "durableArtifactGlob": ".codex/sessions/YYYY/MM/DD/rollout-*.jsonl", + "freshInteractiveCreatesShellSnapshotBeforeTurn": true, + "freshInteractiveCreatesDurableSessionBeforeTurn": false, + "appServerThreadPathAvailableBeforeArtifact": true, + "appServerMissingPathWatchAccepted": true, + "appServerMissingParentWatchAccepted": true, + "appServerWatchEchoesCallerWatchId": false, + "appServerArtifactMaterializesAtReportedPath": true, + "appServerChangedPathsMentionRolloutPath": false, + "resumeCommandTemplate": "codex --remote <ws> --no-alt-screen resume <threadId>", + "preDurableResumeExperimentCapturedOn": "2026-05-13", + "preDurableResumeCommandTemplate": "codex --remote <ws> --no-alt-screen resume <threadId>", + "preDurableResumeBeforeRolloutWorks": false, + "preDurableResumeFailureFragment": "no rollout found for thread id", + "freshRemoteThreadStartedDelayMs": 641, + "preDurableIdentityCaptureStrategy": "Launch fresh remote TUI only after listener installation, then block user input until thread/started is persisted.", + "codexIdentityNames": { + "rootTuiThreadId": "Provider ThreadId observed from thread/start or thread/started for the root TUI thread.", + "rolloutProofId": "The payload.id value from the first rollout JSONL record when type is session_meta.", + "resumeId": "The same root TUI ThreadId passed to codex --remote <ws> --no-alt-screen resume <threadId>.", + "ambiguousTermsToAvoid": [ + "generic session id", + "provider session_id" + ] + }, + "turnCompletedIsDurabilityProof": false, + "noPollingPromotionSupported": "yes_with_required_completion_proof_check_and_event_driven_repair", + "noPollingCanonicalPromotionStrategy": "Use turn/completed for the candidate root TUI ThreadId as the normal proof-check boundary. On that event, immediately do one exact proof read of the stored provider-reported rollout path and promote only if the first JSONL record is matching session_meta. fs/changed, later Codex events, PTY exit, app-server websocket close/error, and user-initiated restore/list/open actions are repair opportunities, not the normal success path.", + "noPollingPromotionGuarantee": "No periodic or backoff existence/read loop. Durable restore is allowed to be unproven before a Codex turn completes. After turn/completed, a missing, unreadable, empty, malformed, or mismatched rollout proof is durability_unproven_after_completion and must be visible as degraded/error state.", + "proofReadContract": { + "trigger": "turn/completed", + "path": "stored provider-reported rolloutPath", + "read": "one exact read of the rollout path", + "success": "regular readable JSONL file whose first record has type session_meta and payload.id equal to candidateThreadId", + "failureStateAfterTurnCompleted": "durability_unproven_after_completion", + "timerLoopAllowed": false + }, + "durabilityStateModel": { + "identity_pending": { + "canonical": false, + "userInput": "blocked", + "sidebar": "Starting Codex; restore identity not captured.", + "userCan": "wait, close, or start a fresh pane" + }, + "captured_pre_turn": { + "canonical": false, + "userInput": "allowed after the candidate write succeeds", + "sidebar": "Codex identity captured; restore proof pending before first turn.", + "userCan": "work in the live terminal" + }, + "turn_in_progress_unproven": { + "canonical": false, + "userInput": "allowed while live terminal is healthy", + "sidebar": "Codex turn running; restore proof pending.", + "userCan": "continue live work, with restore not yet guaranteed" + }, + "proof_checking": { + "canonical": false, + "userInput": "allowed if the live terminal remains attachable", + "sidebar": "Checking Codex restore proof.", + "userCan": "keep using the live terminal while the exact proof read is in flight" + }, + "durable": { + "canonical": true, + "userInput": "allowed", + "sidebar": "Codex session restorable.", + "userCan": "restore or reopen using the durable root TUI ThreadId" + }, + "durability_unproven_after_completion": { + "canonical": false, + "userInput": "allowed only through an attachable live terminal", + "sidebar": "Codex restore proof failed after turn completion.", + "userCan": "attach live if available, trigger one-shot repair by restore/list/open, or start fresh" + }, + "non_restorable": { + "canonical": false, + "userInput": "fresh terminal only", + "sidebar": "Codex session not restorable.", + "userCan": "open a fresh Codex terminal" + } + }, + "repairTriggers": [ + { + "name": "later_codex_event", + "semantics": "On a later Codex notification/response that is deterministically tied to the candidate root TUI ThreadId, run one exact proof read. Promote on success; remain degraded on failure." + }, + { + "name": "fs_changed", + "semantics": "On fs/changed for the exact rollout path or watched parent, run one exact proof read. Promote on success; remain degraded on failure." + }, + { + "name": "pty_exit", + "semantics": "On PTY exit, run one exact proof read before deciding whether the captured session is durable, still pre-completion lenient, or non_restorable." + }, + { + "name": "app_server_websocket_close_or_error", + "semantics": "On app-server websocket close/error, run one exact proof read for the captured candidate. Promote on success; otherwise keep or enter degraded/non-restorable state according to live terminal availability." + }, + { + "name": "user_restore_list_open", + "semantics": "On user restore, list, or open for a captured-but-unproven Codex session, run one exact proof read first. This is a repair path, not the normal success path." + } + ], + "capturedUnprovenReopenPolicy": { + "firstStep": "Run one exact proof read of the stored rolloutPath.", + "onProofSuccess": "Promote to durable and resume with the proven root TUI ThreadId.", + "onProofFailureLiveAttachable": "Attach the existing live terminal and keep the degraded/unproven state visible.", + "onProofFailureLiveMissing": "Create a fresh Codex terminal and show a clear message that the captured session could not be proven restorable.", + "forbidden": [ + "cwd_time_title_matching", + "shell_snapshot_identity", + "hidden_hook_configuration", + "fake_or_mutating_provider_writes" + ] + }, + "inputGatePurpose": "Block user-originating PTY input only until Freshell has captured and durably saved Codex's candidate root TUI ThreadId and provider-reported rollout path. The gate is not waiting for the rollout file to exist.", + "turnCompletionDurabilityContract": "Before a Codex turn completes, canonical restore may be unproven. When turn/completed arrives for the candidate root TUI ThreadId, Freshell must immediately proof-read the exact rollout path. Completion is the required proof-check boundary, not proof itself, because Codex can warn on rollout flush failure and still complete the turn.", + "mutableNameSurface": "absent" + }, + "claude": { + "executable": "claude", + "resolvedPath": "/home/user/bin/claude", + "isolatedBinaryPath": "/home/user/.local/bin/claude", + "version": "2.1.132 (Claude Code)", + "exactIdCommandTemplate": "HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --session-id <uuid> <prompt>", + "namedResumeCommandTemplate": "HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --resume <title-or-uuid> [--name <title>] <prompt>", + "transcriptGlob": ".claude/projects/*/<uuid>.jsonl", + "canonicalIdentity": "uuid-transcript", + "namedResumeWorksInPrintMode": true, + "renameMutatesMetadataOnly": true, + "oldTitleStopsResolvingAfterRename": true, + "oldTitleErrorFragment": "does not match any session title" + }, + "opencode": { + "executable": "opencode", + "resolvedPath": "/home/user/.opencode/bin/opencode", + "version": "1.14.41", + "runCommandTemplate": "opencode run <prompt> --format json --dangerously-skip-permissions", + "serveCommandTemplate": "opencode serve --hostname 127.0.0.1 --port <port>", + "globalHealthPath": "/global/health", + "sessionStatusPath": "/session/status", + "canonicalIdentity": "session-id", + "runEventSessionIdMatchesDbId": true, + "busyStatusUsesAuthoritativeSessionId": true, + "titleOnResumeMutatesStoredTitle": false, + "sessionSubcommands": [ + "list", + "delete" + ] + } + } +} +``` + +## Process audit and cleanup + +The live process audit was run with: + +```bash +ps -eo pid,ppid,stat,cmd --sort=pid | rg "codex|claude|opencode" +``` + +That audit showed live user sessions for all three providers outside the temp homes used for the probes. Those processes must never be stopped by cleanup. + +The checked-in harness therefore only stops processes when both provenance checks succeed: + +1. `FRESHELL_PROBE_HOME` matches the current temp root. +2. `FRESHELL_PROBE_SENTINEL` matches the current sentinel file. + +Before cleanup runs, the harness emits a dry-run ownership report containing `pid`, `ppid`, `cwd`, `tempHome`, `sentinelPath`, `safeToStop`, and `command` for every candidate PID in the probe-owned process tree. Cleanup aborts if any candidate lacks the expected temp-home or sentinel metadata. + +## Codex evidence + +### Version + +```bash +command -v codex +# /home/user/.npm-global/bin/codex + +codex --version +# codex-cli 0.130.0 +``` + +This `2026-05-13` version refresh supersedes the older `codex-cli 0.129.0` capture. The current version of record on this machine is `codex-cli 0.130.0`. + +### Fresh remote startup + +Fresh remote bootstrap was probed with a loopback websocket stub and: + +```bash +CODEX_HOME=<temp-root>/.codex codex --remote <ws> --no-alt-screen +``` + +Before any user turn, the CLI opened a connection and issued: + +1. `initialize` +2. `initialized` +3. `account/read` +4. `account/read` +5. `model/list` +6. `thread/start` + +That proves fresh `codex --remote` allocates a thread during bootstrap, before the first user turn. This thread allocation is useful for preventing untracked user work, but it is not yet the durable restore identity. + +### Remote resume + +The remote resume form was re-proved through a websocket proxy in front of the real app-server. Before any user turn, `codex --remote <ws> --no-alt-screen resume <threadId>` issued the stable prefix through `thread/resume`, followed by `skills/list`, `account/rateLimits/read`, `command/exec`, `hooks/list`, and `thread/goal/get` calls. + +The trailing post-resume follow-up order varied between reruns on the same binary, so only the stable prefix plus the required follow-up method set is treated as contract. + +### Durable artifact creation + +Real provider-owned durability was re-proved against the app-server websocket with: + +```bash +CODEX_HOME=<temp-root>/.codex codex app-server --listen <ws> +# JSON-RPC: +# initialize +# thread/start +# turn/start +# thread/resume +``` + +Observed provider-owned artifacts: + +- After `thread/start` and before `turn/start`: a shell snapshot under `.codex/shell_snapshots/*.sh`. +- After `thread/start` and before `turn/start`: no `.codex/sessions/**.jsonl` durable artifact. +- `thread/start` already returned `thread.ephemeral: false` and a concrete `thread.path` under `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. +- Immediately after `thread/start`, neither the rollout file nor its date directory existed yet. +- `fs/watch` accepted caller-supplied `watchId` values for both the missing rollout path and the missing parent directory and returned only the canonicalized watched `path`. +- After the first real `turn/start`: a durable artifact under `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. +- After the first real `turn/start`: the durable artifact appeared at the exact `thread.path`. +- In the `2026-04-26` rerun, no `fs/changed` notification was observed for the newly materialized rollout path within the historical timeout, so durable detection must not depend on that notification. + +Short JSON-ish transcript from the `2026-04-26` rerun: + +```json +{ + "thread/start": { + "thread": { + "id": "<uuid>", + "ephemeral": false, + "path": "<temp-root>/.codex/sessions/2026/04/23/rollout-...jsonl" + } + }, + "preTurn": { + "rolloutExists": false, + "parentExists": false + }, + "fs/watch": [ + { + "watchId": "probe-rollout-path", + "result": { "path": "<same rollout path>" } + }, + { + "watchId": "probe-rollout-parent", + "result": { "path": "<same parent directory>" } + } + ], + "fs/changed": null +} +``` + +The durable restore path that worked after restarting the app-server runtime was: + +```bash +thread/resume <threadId> +turn/start <threadId> +``` + +### Pre-durable resume and input-gating experiment + +This targeted `2026-05-13` experiment tested whether Freshell can prevent un-restorable fresh Codex work by pre-creating the Codex app-server thread, persisting that `thread.id`, and then launching the user-facing TUI against the pre-created thread before any rollout artifact exists. + +The isolated setup used: + +```bash +CODEX_HOME=<temp-root>/.codex /home/user/.npm-global/bin/codex app-server --listen ws://127.0.0.1:<port> + +# JSON-RPC over the app-server websocket: +# initialize +# thread/start { cwd: <temp-cwd>, persistExtendedHistory: true } + +CODEX_HOME=<same-temp-root>/.codex /home/user/.npm-global/bin/codex --remote ws://127.0.0.1:<port> --no-alt-screen resume <threadId> +``` + +Result: + +- `thread/start` returned a persistable `thread.id` and exact future `thread.path`. +- The rollout artifact and parent date directory did not exist immediately after `thread/start`. +- The pre-created-thread TUI resume read that exact in-memory thread successfully with `thread/read`. +- The same TUI then failed `thread/resume` with `no rollout found for thread id <threadId>`. +- No real model prompt was sent during the failed pre-durable resume experiment. + +Measured timings from the isolated run: + +| Phase | Elapsed | +| --- | ---: | +| app-server spawn to websocket accepting | 316.9 ms | +| `initialize` request to response | 33.9 ms | +| `thread/start` request to response | 559.7 ms | +| first `thread/started` notification from probe start | 1006.9 ms | +| pre-durable resume TUI spawn to proxy connection | 450.3 ms | +| pre-durable resume TUI `thread/read` success | 2.9 ms | +| pre-durable resume TUI `thread/resume` failure | 1.9 ms | +| fresh remote TUI spawn to `thread/start` response | 638.0 ms | +| fresh remote TUI spawn to `thread/started` notification | 640.9 ms | + +Conclusion: + +- Pre-creating a thread via app-server and then attaching the user-facing TUI with `codex resume <threadId>` before rollout materialization is not a viable prevention strategy. +- Fresh remote TUI launch after listener installation is viable for identity capture: in this run the thread identity was available about 641 ms after TUI spawn. +- To prevent untracked user work, Freshell must block terminal input until `thread/started` has been observed and the pre-durable candidate identity has been persisted. +- The pre-durable `thread.id` is useful as a captured candidate identity, but it is not a durable restore identity until the exact rollout artifact exists at the provider-reported `thread.path`. + +### Codex source-code study + +This study used the installed launcher at `/home/user/.npm-global/lib/node_modules/@openai/codex` and the official upstream source `openai/codex` tag `rust-v0.130.0`, commit `58573da43ab697e8b79f152c53df4b42230395a8`, cloned at `/tmp/codex-rust-v0.130.0`. The installed npm package contains the JavaScript native-binary launcher and package metadata; the Rust TUI, app-server, protocol, thread-store, and rollout code live in the official upstream repository. + +Source locations studied: + +- `/home/user/.npm-global/lib/node_modules/@openai/codex/package.json`: version `0.130.0`, upstream repository `https://github.com/openai/codex.git`, package directory `codex-cli`, and platform-native optional dependencies. +- `/home/user/.npm-global/lib/node_modules/@openai/codex/bin/codex.js`: locates the platform binary and execs it with inherited stdio; it does not implement session identity. +- `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/lib.rs`: remote app-server connection and resume lookup. +- `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app.rs`: fresh/resume startup ordering and TUI input event loop. +- `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app_server_session.rs`: TUI JSON-RPC calls for `thread/start`, `thread/resume`, `thread/read`, and `turn/start`. +- `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/session_state.rs`: TUI stores `thread_id` and optional `rollout_path`. +- `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs`: `thread/start`, `thread/read`, and `thread/resume` behavior. +- `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/turn_processor.rs`: `turn/start` converts app-server input into core `Op::UserInput`. +- `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/fs_watch.rs` and `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/fs.rs`: `fs/watch` and `fs/changed`. +- `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/common.rs`: public JSON-RPC method set, including `fs/watch`, `turn/start`, and no public `thread/persist`-style method. +- `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs`: `thread.path` is explicitly marked `[UNSTABLE]`. +- `/tmp/codex-rust-v0.130.0/codex-rs/core/src/thread_manager.rs`, `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/session.rs`, and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/mod.rs`: session startup, `SessionConfigured`, rollout path propagation, and materialization hooks. +- `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/turn.rs` and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/handlers.rs`: first user input is recorded and then forces rollout materialization. +- `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs`: fresh rollout path precomputation, deferred writer open, `persist()`, `flush()`, and `session_meta` write ordering. +- `/tmp/codex-rust-v0.130.0/codex-rs/thread-store/src/local/read_thread.rs`: stored-thread lookup, rollout existence checks, and the `no rollout found for thread id` path. +- `/tmp/codex-rust-v0.130.0/codex-rs/core/src/hook_runtime.rs`: `SessionStart` hook transcript path obtains a materialized rollout internally. +- `/tmp/codex-rust-v0.130.0/codex-rs/core/src/shell_snapshot.rs`: shell snapshot lifecycle. + +#### Remote TUI startup and candidate identity + +The remote TUI connects with `client_name: "codex-tui"`, `experimental_api: true`, and `opt_out_notification_methods: Vec::new()` in `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/lib.rs:378`. That means Freshell can observe normal app-server responses and notifications when it owns the remote websocket proxy. + +For fresh sessions, `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app.rs:734` awaits `app_server.start_thread(&config)` before it constructs the chat widget; `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app_server_session.rs:328` sends `ClientRequest::ThreadStart`; `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app_server_session.rs:1329` copies `response.thread.id` and `response.thread.path` into `ThreadSessionState`; and `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/session_state.rs:27` stores `thread_id` plus optional `rollout_path`. + +On the app-server side, `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1102` builds the API thread from the `SessionConfigured` event, including `session_configured.rollout_path`; `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1156` builds the `ThreadStartResponse`; `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1170` creates the `thread/started` notification; and `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1171` sends the response before `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1180` sends the notification. Therefore a Freshell websocket proxy can capture the same candidate from either the `thread/start` response or the later `thread/started` notification. The notification is useful as a provider event surface, but the response is the earlier source-supported surface. + +The TUI itself does not start its main terminal event loop until after it enqueues the started thread in `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app.rs:913`. Once the loop is running, `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app.rs:1018` reads terminal events and `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app.rs:1082` dispatches keys and paste. Freshell still needs a PTY-side input gate because terminal bytes can be queued outside Codex before Freshell has atomically persisted the observed candidate. + +#### Rollout path is announced before the rollout exists + +The app-server integration test at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/tests/suite/v2/thread_start.rs:147` asserts the fresh `thread.path` is absolute and `/tmp/codex-rust-v0.130.0/codex-rs/app-server/tests/suite/v2/thread_start.rs:149` asserts it does not yet exist. The same test waits for the `thread/started` notification at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/tests/suite/v2/thread_start.rs:186` and asserts no preceding `thread/status/changed` for the new thread at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/tests/suite/v2/thread_start.rs:194`. + +The rollout recorder explains why. In `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:680`, the create path calls `precompute_log_file_info`, captures `path`, and constructs `SessionMeta`, but returns `None` for the writer and `Some(log_file_info)` at `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:718`. A fresh thread therefore has an in-memory rollout path and session metadata before the file is opened. + +Materialization happens only when persistence is forced or pending items require a write. `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1494` makes `add_items` a no-op for the filesystem while the writer is deferred; `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1503` makes `persist()` write even when there are no pending items; `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1507` makes `flush()` return without creating a file when the writer is deferred and there are no pending items; `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1576` opens the deferred writer; `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1614` opens the writer, writes session metadata, writes pending items, and flushes; and `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1714` writes `RolloutItem::SessionMeta`. + +The metadata line has the durable root TUI `ThreadId` Freshell needs to validate. `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2703` defines `SessionMeta { id: ThreadId, ... }`; `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2759` defines the JSONL `SessionMetaLine`; and `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2767` wraps it as the `session_meta` rollout item. Because the writer opens before the first line is written, a plain `exists()` check can observe a transient empty file. The deterministic promotion proof should require the exact provider-reported path to exist, be readable as JSONL, and begin with `payload.id == candidateThreadId` on a `session_meta` record. + +`thread.path` is useful but not a stable protocol guarantee by itself: `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs:125` marks the path field `[UNSTABLE]`. Freshell should version/probe this provider surface and keep the direct rollout proof as the durable promotion gate. + +#### First user input is the materialization trigger + +`turn/start` is the first app-server request that accepts user work. `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/app_server_session.rs:520` sends `ClientRequest::TurnStart`; `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/turn_processor.rs:348` maps app-server input to core input items; `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/turn_processor.rs:449` starts the turn by submitting `Op::UserInput` or `Op::UserInputWithTurnContext`; and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/handlers.rs:233` creates the turn context before `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/handlers.rs:239` steers user input into the active turn. + +After the prompt is accepted, `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/turn.rs:328` records the user prompt, and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/mod.rs:2976` persists the prompt to history, emits the UI item, then calls `ensure_rollout_materialized()` at `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/mod.rs:2990`. That method calls `live_thread.persist()` through `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/mod.rs:1072`. + +Before Codex emits turn completion, `/tmp/codex-rust-v0.130.0/codex-rs/core/src/tasks/mod.rs:396` calls `sess.flush_rollout().await`. The important caveat is the error path: `/tmp/codex-rust-v0.130.0/codex-rs/core/src/tasks/mod.rs:397` logs the flush failure, `/tmp/codex-rust-v0.130.0/codex-rs/core/src/tasks/mod.rs:398` through `/tmp/codex-rust-v0.130.0/codex-rs/core/src/tasks/mod.rs:406` sends a warning that the transcript failed to save and Codex will retry, and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/tasks/mod.rs:410` still finishes the task when the turn was not cancelled. The app-server exposes that task finish as `turn/completed`: `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/common.rs:1429` defines the notification method, and `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/bespoke_event_handling.rs:1278` through `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/bespoke_event_handling.rs:1299` emits it with the thread id and turn id. + +This gives Freshell a practical no-polling contract, but only if the boundary is named precisely: `turn/completed` is the required proof-check boundary, not proof itself. Durable restore does not need to be proven before the first Codex turn completes. When the turn-completed event arrives for the captured root TUI `ThreadId`, Freshell must immediately do one proof read of the exact provider-reported rollout path. If that proof read fails, the session enters `durability_unproven_after_completion`, a visible restore-durability failure state. It is not acceptable to leave it green, grey, live-only, or captured-not-canonical as a normal steady state past turn completion. + +The reason to block typing is therefore narrow. The gate is not waiting for durable restore. The gate only prevents the user's first prompt from reaching Codex before Freshell has captured and saved the Codex candidate root TUI `ThreadId` and provider-reported rollout path. Once that candidate is durably saved by Freshell, user input can be released even though the rollout file may not exist yet. + +This proves the normal first-turn path should materialize the rollout promptly after the user prompt is accepted and should flush it before turn completion. It does not prove a zero-risk crash window between forwarding the first `turn/start` and observing a parseable rollout file. No public app-server method named like `thread/persist` or `thread/materialize` appears in the public request set around `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/common.rs:699` through `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/common.rs:777`. Therefore strict prevention of all un-restorable first-turn bytes is not source-supported by a public pre-turn materialization RPC in this version. Under the accepted product leniency, that is tolerable only until the first turn completes. + +#### Why pre-create plus TUI resume is not viable + +The TUI resume lookup path explains the mixed result from the `2026-05-13` experiment. `/tmp/codex-rust-v0.130.0/codex-rs/tui/src/lib.rs:579` parses a UUID and calls `thread/read(... include_turns=false)`. The app-server allows metadata-only reads from live in-memory state before persistence: `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:1950` falls back to a live thread snapshot when persisted metadata is missing. + +`thread/resume` is different. `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:2290` handles resume; `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:2336` first tries to resume a running thread; but `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:2637` still calls `read_stored_thread_for_resume(... include_history=true)` for a running thread id before it attaches. The local thread store requires an existing rollout: `/tmp/codex-rust-v0.130.0/codex-rs/thread-store/src/local/read_thread.rs:66` resolves the rollout path, `/tmp/codex-rust-v0.130.0/codex-rs/thread-store/src/local/read_thread.rs:68` returns `no rollout found for thread id` if it cannot, and `/tmp/codex-rust-v0.130.0/codex-rs/thread-store/src/local/read_thread.rs:168` only accepts the live writer path when `try_exists(path)` is true. The app-server maps thread-store misses to the same `no rollout found for thread id` error at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/request_processors/thread_processor.rs:3574`. + +That source path proves the pre-created id can be readable as live metadata and still be non-resumable. + +#### fs/watch, shell snapshots, hooks, and provider events + +`fs/watch` is a wake-up source, not a proof. The protocol accepts an absolute path with a connection-scoped `watch_id` in `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/fs.rs:160`, and `fs/changed` echoes the `watch_id` plus changed paths at `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/fs.rs:195`. The implementation registers the requested path without an existence check in `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/fs_watch.rs:118`, emits sorted changed paths joined under the watch root at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/fs_watch.rs:165`, and returns only `FsWatchResponse { path }` at `/tmp/codex-rust-v0.130.0/codex-rs/app-server/src/fs_watch.rs:185`. The underlying watcher is `notify::recommended_watcher` in `/tmp/codex-rust-v0.130.0/codex-rs/core/src/file_watcher.rs:327`; missing targets are watched through the nearest existing ancestor in `/tmp/codex-rust-v0.130.0/codex-rs/core/src/file_watcher.rs:736`; the OS watch is skipped if the actual path does not exist in `/tmp/codex-rust-v0.130.0/codex-rs/core/src/file_watcher.rs:556`; and matching reports the requested path only when an event plus current existence state reaches the requested target in `/tmp/codex-rust-v0.130.0/codex-rs/core/src/file_watcher.rs:811`. Codex's own fs-watch tests explicitly avoid failing when no OS event arrives in `/tmp/codex-rust-v0.130.0/codex-rs/app-server/tests/suite/v2/fs.rs:684`, and the real probe observed no `fs/changed` before timeout. Therefore an event-driven Freshell implementation may subscribe to the exact rollout path and parent, but an `fs/changed` event cannot be the only event source and cannot replace a direct proof read. + +Shell snapshots are not identity. `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/session.rs:699` starts shell snapshotting during session startup when the feature is enabled; `/tmp/codex-rust-v0.130.0/codex-rs/core/src/shell_snapshot.rs:39` keys the snapshot by session id and cwd; and `/tmp/codex-rust-v0.130.0/codex-rs/core/src/shell_snapshot.rs:153` writes and validates a temporary shell environment file before renaming it. The snapshot can appear before the rollout and can help diagnose startup, but it is deleted on drop and is not used by `thread/resume` as durable session history. + +Hooks expose an internal materialization path but not a Freshell startup contract. `/tmp/codex-rust-v0.130.0/codex-rs/core/src/hook_runtime.rs:104` runs pending `SessionStart` hooks on the first turn context and includes `transcript_path: sess.hook_transcript_path().await` at `/tmp/codex-rust-v0.130.0/codex-rs/core/src/hook_runtime.rs:115`; `hook_transcript_path()` calls `ensure_rollout_materialized()` at `/tmp/codex-rust-v0.130.0/codex-rs/core/src/session/mod.rs:3284`. That could force materialization for Codex-owned hook execution, but Freshell should not rely on configuring hidden provider hooks to create identity; it is not a public app-server session-start barrier, and it changes provider configuration semantics. + +Provider events split into candidate, proof-check, and repair surfaces. `thread/start` response and `thread/started` notification carry the candidate before user work. `turn/start` proves user work has already been accepted, so it is too late to protect candidate capture. `turn/completed` is the normal no-polling proof-check trigger: by then Codex should have materialized and flushed the rollout, but source shows flush failure can still warn and continue to completion. `fs/changed`, later Codex events, PTY exit, app-server websocket close/error, and user restore/list/open actions are deterministic repair opportunities, not the main path. + +#### Codex identity names + +For Codex, Freshell should be explicit about identity terms: + +| Name | Meaning | +| --- | --- | +| `rootTuiThreadId` | The `ThreadId` for the user-facing root TUI thread, observed from `thread/start` or `thread/started`. | +| `candidateThreadId` | A persisted non-canonical copy of `rootTuiThreadId` before rollout proof succeeds. | +| `rolloutPath` | The provider-reported `thread.path` for that candidate. It is useful but marked `[UNSTABLE]` in `/tmp/codex-rust-v0.130.0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs:125`. | +| `rolloutProofId` | The `payload.id` from the first JSONL rollout record when `type == "session_meta"`. `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2703` through `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2705` define that id as a `ThreadId`. | +| `durableThreadId` | The canonical identity after `rolloutProofId == candidateThreadId` at the exact `rolloutPath`. This is also the id passed to `codex --remote <ws> --no-alt-screen resume <threadId>`. | + +Avoid generic "session id" in Codex restore design because it can be confused with provider fields named `sessionId` or `session_id`. The durable Codex identity in this contract is the root TUI `ThreadId`; the rollout proof is the first JSONL line shaped like `{"type":"session_meta","payload":{"id":"<ThreadId>", ...}}`. That shape follows the tagged `RolloutItem` wrapper in `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2767` through `/tmp/codex-rust-v0.130.0/codex-rs/protocol/src/protocol.rs:2770`, and the recorder writes that `RolloutItem::SessionMeta` at `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1738` through `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1740`. + +#### State model + +| State | Meaning | What the user can do | Sidebar/state surface | +| --- | --- | --- | --- | +| `identity_pending` | Fresh Codex is starting, but Freshell has not persisted a `candidateThreadId` plus `rolloutPath`. | Wait, close the pane, or start a fresh pane. User-originating PTY input is blocked. | "Starting Codex; restore identity not captured." No restorable indicator. | +| `captured_pre_turn` | Freshell has persisted the candidate before a user turn is accepted, but the rollout proof is not expected yet. | Use the live terminal after the candidate write succeeds. | "Codex identity captured; restore proof pending." Neutral pending state, not green. | +| `turn_in_progress_unproven` | A Codex turn is running for the captured candidate and durable proof has not succeeded. | Continue live work while the terminal is attachable. Restore is not guaranteed yet. | "Codex turn running; restore proof pending." Not an error before completion. | +| `proof_checking` | `turn/completed` arrived or a repair trigger fired, and Freshell is doing one exact proof read. | Keep using the live terminal if it remains attachable. | "Checking Codex restore proof." Short-lived pending state. | +| `durable` | The exact rollout proof succeeded, so `durableThreadId` is canonical. | Reopen, resume, split, or restore using the durable root TUI `ThreadId`. | Normal restorable Codex session. | +| `durability_unproven_after_completion` | A proof read failed after `turn/completed` for the candidate. | Attach the live terminal if available, trigger user repair by restore/list/open, or start fresh. | Visible degraded/error state: "Codex restore proof failed after turn completion." | +| `non_restorable` | There is no captured candidate, or the captured candidate cannot be proven and no live terminal can be attached. | Open a fresh Codex terminal. | Clear non-restorable error. No fake resume affordance. | + +The state model intentionally accepts leniency before a Codex turn completes. After `turn/completed`, proof failure is not a normal grey state. It is `durability_unproven_after_completion` until a deterministic repair trigger succeeds or the live terminal is gone and the pane becomes `non_restorable`. + +#### Failure handling at `turn/completed` + +When `turn/completed` arrives for the captured root TUI `ThreadId`, Freshell must transition to `proof_checking` and immediately run exactly one proof read of the stored `rolloutPath`. The proof succeeds only if the path is a regular readable JSONL file and the first record is parseable `session_meta` with `payload.id == candidateThreadId`. A mere path existence check is too weak because `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1576` through `/tmp/codex-rust-v0.130.0/codex-rs/rollout/src/recorder.rs:1622` opens the deferred writer, writes session metadata, writes pending items, and then flushes. + +If the proof read succeeds, Freshell promotes to `durable`, persists the canonical `sessionRef`, and may display normal restore affordances. If it fails after `turn/completed`, Freshell must immediately surface `durability_unproven_after_completion`. The live PTY may remain usable if it is still attachable, but the sidebar and pane state must not silently present it as durable, green, or harmlessly pending. + +There is no periodic, delayed, or backoff read loop. If a proof read is already in flight and another deterministic trigger arrives, Freshell may coalesce the trigger into at most one additional exact read after the current read resolves. It must not keep retrying because time passed. + +#### Proof-to-layout bridge + +A successful Codex durability proof is not complete at the registry binding step. The implementation invariant is: + +`rollout proof succeeds -> server binds/rebinds the terminal to the proven Codex thread id -> the browser receives terminal.session.associated with sessionRef { provider: "codex", sessionId: durableThreadId } -> TerminalView writes that canonical sessionRef into the terminal pane, and into the tab when the tab is a single-pane terminal tab -> TerminalView dispatches flushPersistedLayoutNow`. + +`terminal.session.bound` is a server-local registry event. It may be useful for ownership, activity tracking, lifecycle logs, and metadata, but it is not by itself a persisted browser layout update. Likewise, `terminal.codex.durability.updated` can persist candidate and durability state, but it does not replace `terminal.session.associated` as the canonical terminal durable-promotion event. + +The current `/home/user/code/freshell/.worktrees/dev` implementation has two bridge surfaces that should stay covered by tests: `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:2017` binds the proven Codex id, `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:2051` through `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:2052` broadcasts `terminal.session.associated` to attached clients, and `/home/user/code/freshell/.worktrees/dev/server/index.ts:442` through `/home/user/code/freshell/.worktrees/dev/server/index.ts:459` converts Codex `terminal.session.bound` into the shared association publisher path with `source: "codex_durability"`. On the client, `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2189` through `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2234` is the persistence boundary: it accepts `terminal.session.associated`, builds the canonical sessionRef update, updates pane/tab state, and flushes the persisted layout. + +Focused verification should prove both halves. A server test should start from a matching Codex rollout proof and assert a client-visible `terminal.session.associated` publication, not only `terminal.session.bound` or `codex_durable_session_observed`. A client test should show that `terminal.created` stays live-only and that pane/tab `sessionRef` plus the immediate layout flush happen only after `terminal.session.associated`. + +#### Deterministic repair triggers + +Each repair trigger below performs one exact proof read of the stored `rolloutPath`. Success promotes to `durable`. Failure keeps `durability_unproven_after_completion` after a completed turn, or keeps the pre-completion unproven state before a completed turn. User actions are repair paths, not the normal success path. + +| Trigger | Semantics | +| --- | --- | +| Later Codex event | A later Codex notification/response deterministically tied to the candidate root TUI `ThreadId` may trigger one proof read. Generic app-server noise that cannot be tied to the candidate is ignored. | +| `fs/changed` | A notification for the exact rollout path or watched parent may trigger one proof read. The notification is only a wake-up source and does not prove durability. | +| PTY exit | Before marking the session gone, Freshell runs one proof read. If it fails after completion and no live terminal remains, the state becomes `non_restorable`; if it fails before completion, it stays within the accepted pre-completion leniency but still is not durable. | +| App-server websocket close/error | Close/error from the app-server observer or TUI connection triggers one proof read for the captured candidate. Success promotes; failure stays degraded or becomes non-restorable depending on live attachability. | +| User restore/list/open | A user attempt to restore, list, or open a captured-but-unproven Codex session runs one proof read first. This can repair a missed provider/filesystem event, but it is not the normal success path. | + +#### Re-open/resume policy for captured-but-unproven sessions + +If the user attempts to re-open or resume a captured-but-unproven Codex session, Freshell must proof-read first. If proof succeeds, it promotes to `durable` and resumes with the proven root TUI `ThreadId`. If proof fails and the live terminal is attachable, Freshell attaches the live terminal and keeps the degraded/unproven state visible. If proof fails and no live terminal is attachable, Freshell creates a fresh Codex terminal with a clear local message/state explaining that the captured Codex session could not be proven restorable. + +This path must not use cwd, launch time, title, pane title, shell snapshots, hidden hook configuration, or fake/mutating provider writes. It also must not try `codex resume <candidateThreadId>` before proof succeeds; the real-binary experiment and the thread-store source prove that a live-readable pre-durable id can still fail resume with `no rollout found for thread id`. + +#### Approach evaluation + +| Approach | Source proof | Failure mode | Use in Freshell | +| --- | --- | --- | --- | +| Pre-create app-server thread, then TUI `resume <threadId>` | `thread/read include_turns=false` can return live metadata before persistence, but `thread/resume` requires stored rollout history and path existence. | Fails before rollout with `no rollout found for thread id`; this matches the real-binary experiment. | Do not use. | +| Fresh remote TUI after listener/proxy install | TUI awaits `thread/start` before its main input loop, app-server sends `thread/start` response then `thread/started`, both with `thread.id` and `thread.path`. | If Freshell starts Codex before installing the proxy/listener or before its own persistence transaction is ready, early terminal bytes can race identity capture. | Use. Install proxy/listeners first. | +| PTY input blocking | TUI reads keys/paste after startup; Freshell controls the PTY input boundary. | Without a Freshell gate, queued bytes can enter Codex before the candidate is durably recorded by Freshell. | Use. Block user-originating stdin until the candidate is atomically persisted. | +| App-server-side `turn/start` interception | `turn/start` is the app-server request that submits `Op::UserInput`. | Intercepting it as the primary guard is late: the user already typed. Forwarding it before candidate persistence creates untracked work. | Use only as a secondary safety net in the websocket proxy: reject or hold `turn/start` if the candidate is missing. | +| Exact rollout-path watch plus proof | `fs/watch` accepts the path and can notify, while rollout writer writes `session_meta` first when materialized. | `fs/changed` is not guaranteed by source tests or the probe; `exists()` alone can observe an empty file between open and first write. | Use watch only as one explicit event source; do a one-shot proof read on each event and promote only after parseable `payload.id` on `session_meta` matches the candidate. | +| Turn-completed proof check | Codex normally materializes after recording the first prompt and attempts to flush before completing the turn, but the flush error path can warn and still finish the task. | If the proof read fails after `turn/completed`, Freshell has evidence of a restore-durability failure, not proof that durability exists. | Use as the required proof-check boundary. Promote only after the exact rollout proof succeeds. | +| Shell snapshot identity | Shell snapshots are startup environment files keyed by session id and cwd, separate from `thread/resume` history. | Snapshot may exist before rollout, is deleted on drop, and is not consulted by resume. | Do not use as identity or promotion proof. | +| Provider event promotion | `thread/start` response and `thread/started` are pre-user-work candidate surfaces; `turn/start` and turn notifications are post-acceptance surfaces. | Promoting on `thread/started` alone treats an unmaterialized future path as durable. Waiting for turn events cannot prevent first-turn loss. | Use start response/notification for candidate only; promote on rollout proof only. | +| Hidden hook-based materialization | `SessionStart` hooks call `hook_transcript_path()`, which materializes internally. | Requires provider hook configuration and only runs in the first-turn hook path; not a stable external session-start API. | Do not use. | +| Mutating API calls to force persistence | Methods like injecting items can write history, but they mutate provider-visible state. No public no-op materialize method was found in this source pass. | Creates fake history or hidden behavior. | Do not use; under the current constraints there is no source-supported no-op materialization path. | + +#### Practical Freshell contract + +1. Fresh Codex launch starts in `identity_pending`. Freshell installs the remote websocket proxy/listeners and prepares its own atomic persistence before spawning `codex --remote`. +2. While `identity_pending`, Freshell forwards provider output, resize signals, and narrow terminal-control replies required for TUI startup, but not user-originating PTY input. The UI should show a clear starting state rather than silently accepting untracked work. +3. Freshell captures the first valid candidate from either the `thread/start` response or the `thread/started` notification. The candidate must have `ephemeral == false`, a non-empty root TUI `ThreadId`, and a provider-reported absolute `rolloutPath`. +4. Freshell atomically persists the candidate as non-canonical state: provider `codex`, candidate root TUI `ThreadId`, `rolloutPath`, source event/response, CLI version, capture timestamp, and durability state. +5. After that write succeeds, Freshell transitions to `captured_pre_turn` and may unblock user-originating PTY input. This prevents unknown-thread work, but it does not claim the first prompt is restorable. +6. During `turn_in_progress_unproven`, live use may continue. Canonical restore remains unproven and the sidebar must not show durable/green restore state. +7. On `turn/completed` for the captured root TUI `ThreadId`, Freshell transitions to `proof_checking` and performs one exact proof read of the stored `rolloutPath`. +8. Freshell promotes to `durable` only after the proof read finds a regular readable JSONL file whose first record is `type == "session_meta"` and whose `payload.id == candidateThreadId`. +9. If the proof read fails after `turn/completed`, Freshell transitions to `durability_unproven_after_completion`, shows a degraded/error state immediately, and keeps the live terminal attachable only as live terminal access. +10. Freshell registers deterministic repair triggers but never starts a periodic or backoff existence/read loop. Each trigger is one exact proof read. +11. If the process exits before candidate capture, report `non_restorable` and never infer identity from cwd, time, title, or shell snapshot. If it exits after candidate capture but before a turn completes, do one final proof read; a failed proof is still pre-completion leniency, not durability. +12. The residual unproven gap is strict first-turn crash safety. Source shows the normal first user prompt forces rollout materialization, but this version does not expose a public pre-turn materialize RPC. Under the stated constraints, the enforceable boundary is "captured before input, proof check required at turn completion." + +Implementation note from live Freshell validation: the fresh-candidate wait should be a bounded startup deadline, not a polling loop. A 10 second deadline killed a valid cold Codex launch before the app-server exposed candidate identity; use a longer bounded deadline such as 45 seconds, while still keeping PTY input blocked until the candidate is persisted. + +#### Current Freshell implementation gap + +The current `/home/user/code/freshell/.worktrees/dev` implementation does not yet match this contract. `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/durable-rollout-tracker.ts:6` through `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/durable-rollout-tracker.ts:8` define delayed probe intervals, `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/durable-rollout-tracker.ts:164` through `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/durable-rollout-tracker.ts:205` schedules repeated probes, and `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/durable-rollout-tracker.ts:183` promotes on `pathExists()` rather than a first-record `session_meta` proof. Those lines are incompatible with the no-polling proof contract. + +The current app-server client schema handles thread lifecycle notifications and `fs/changed` in `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/protocol.ts:355` through `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/protocol.ts:367`, and dispatches them in `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/client.ts:376` through `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/client.ts:399` and `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/client.ts:447` through `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server/client.ts:482`. This source pass found no `turn/completed` parser under `/home/user/code/freshell/.worktrees/dev/server/coding-cli/codex-app-server`, so the implementation must add a deterministic completion proof-check surface before it can satisfy this contract. + +The current recovery path also has a timer-based live-only success surface: `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:1979` through `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:1997` starts a pre-durable stability timer and marks `running_live_only`. Under the revised contract, a live-only state is acceptable only before a completed turn or while visibly degraded after proof failure; it must not be a silent green/grey steady state after `turn/completed`. + +#### 2026-05-14 central restore decision lesson + +Commit `da2e0076` (`Centralize Codex restore create decisions`) turned one implementation lesson into part of the design contract: deterministic restore is not only rollout-proof logic. Every restore-like create path must enter one typed decision contract before it can spawn, resume, attach, or fall back to a fresh Codex terminal. Otherwise separate entry points can drift into different restore semantics even if the proof reader is correct. + +At the design level, `da2e0076` added `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/server/coding-cli/codex-app-server/restore-decision.ts` with `planCodexCreateRestoreDecision` and `resolveCodexCreateRestoreDecision`, then rewired `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/server/ws-handler.ts` so Codex `terminal.create` and reopen handling route through that module. The module separates canonical durable resume through `sessionRef { provider: 'codex', sessionId: candidateThreadId }`, proof-first handling for captured candidates, attach-live-on-proof-failure, fresh create, and the remaining legacy raw-resume passthrough case. Focused coverage passed for `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/test/unit/server/coding-cli/codex-app-server/restore-decision.test.ts` and `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/test/server/ws-terminal-create-reuse-running-codex.test.ts`. + +The review verdict was pass with concerns. The refactor is a useful boundary, but it does not mean every surface is complete: + +- `legacy_raw_resume_passthrough` still exists for non-restore creates. It should be removed or replaced once callers can provide canonical `sessionRef` or captured candidate state. +- The central module currently trusts the caller's exact-live-terminal lookup to enforce that a live handle matches both `candidateThreadId` and `rolloutPath`. A follow-up should either enforce that match inside the module or make the typed input contract return enough candidate identity for the module to verify it itself. +- Some side-effect branching remains in `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/server/ws-handler.ts` because spawn, attach, broadcast, and error response effects still live there. That is acceptable for the narrow refactor, but the decision surface should stay pure and explicit as more effects move behind it. +- REST, MCP, CLI, and any other future restore-like surfaces must route through the same decision path or an equivalent shared contract. They should not grow parallel semantics for raw resume, candidate proof, live attach, or fresh fallback. +- Tests should include a surface matrix that proves each external entry point reaches the same decision semantics, not only unit tests for the decision module and the current websocket path. + +### Codex allowed behavior + +- Fresh Codex panes may be captured-but-unproven before a turn completes, but user input should not be accepted until the pre-durable candidate root TUI `ThreadId` and provider-reported `rolloutPath` have been captured and persisted. +- Freshell may use `fs/watch` as a wake-up source for Codex durability, but it still needs direct proof at the exact rollout path before promotion. Without polling, a missed filesystem event is repairable only through later deterministic provider/process/user events that each trigger one exact proof read. +- Freshell may only persist canonical Codex identity after the durable `.jsonl` artifact exists at the provider-reported `thread.path` and the first rollout record proves `payload.id == candidateThreadId` on a `session_meta` record. +- Freshell must not treat the bootstrap `thread/start` id as durable restore identity, and must not try to TUI-resume a pre-artifact thread as if it were durable. +- After `turn/completed`, failed proof is `durability_unproven_after_completion`. The user can still attach a live terminal if one exists, but the sidebar/pane state must be visibly degraded until proof succeeds or the session becomes non-restorable. + +`codex --help` in the tested mode did not expose a rename or title mutation flag such as `--name`, so no mutable-name surface was confirmed for Codex in this contract. + +## Claude Code evidence + +### Version + +```bash +command -v claude +# /home/user/bin/claude + +claude --version +# 2.1.132 (Claude Code) +``` + +This Claude Code version line was refreshed on `2026-05-06`; the behavior observations below remain from the `2026-04-26` real-provider proof. + +The wrapper at `/home/user/bin/claude` shells out to `/home/user/.local/bin/claude`. The isolated probes used the actual binary and overrode `HOME` to keep persistence inside the probe temp root. + +### Exact-id durability + +Fresh exact-id durability was probed with: + +```bash +HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --session-id <uuid> "Reply with exactly: claude-home-probe-ok" +``` + +Observed provider-owned artifacts: + +- `.claude/.credentials.json` +- `.claude/policy-limits.json` +- `.claude/projects/*/<uuid>.jsonl` + +The UUID-backed transcript file is the canonical durable identity. + +### Named resume and rename + +Named resume and rename/title mutation were probed with: + +```bash +HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --session-id <uuid> --name probe-name-one "Reply with exactly: named-create-ok" +HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --resume probe-name-one "Reply with exactly: named-resume-ok" +HOME=<temp-home> /home/user/.local/bin/claude --bare --dangerously-skip-permissions -p --resume <uuid> --name probe-name-two "Reply with exactly: renamed-ok" +``` + +Observed rename semantics: + +- The transcript filename and UUID-backed `sessionId` remained stable. +- Claude appended new `custom-title` and `agent-name` metadata lines for the renamed title. +- After rename, the old title no longer resolved in `--resume`. +- The new title resolved, but only as mutable metadata pointing back to the same UUID transcript identity. + +### Claude Code allowed behavior + +- UUID-backed Claude transcript identity is canonical durable identity. +- Named resume values and titles are mutable metadata only. +- Freshell must not persist a mutable title as Claude durable identity. + +## OpenCode evidence + +### Version + +```bash +command -v opencode +# /home/user/.opencode/bin/opencode + +opencode --version +# 1.14.41 +``` + +### Run-event identity + +Fresh isolated runs were probed with: + +```bash +XDG_DATA_HOME=<temp-home>/.local/share XDG_CONFIG_HOME=<temp-home>/.config opencode run "Reply with exactly: opencode-probe-ok" --format json --dangerously-skip-permissions +``` + +Observed durable identity rule: + +- The `2026-04-26` rerun used isolated empty OpenCode data/config roots for the session-identity probes so stale user-local provider configuration could not affect the contract. +- The first JSON `step_start` event carried a `sessionID`. +- That exact `sessionID` matched the `session.id` row written into the isolated OpenCode database. + +### Control surface identity + +The authoritative control surface was probed with: + +```bash +XDG_DATA_HOME=<temp-home>/.local/share XDG_CONFIG_HOME=<temp-home>/.config opencode serve --hostname 127.0.0.1 --port <port> +curl http://127.0.0.1:<port>/global/health +curl http://127.0.0.1:<port>/session/status +``` + +Observed control behavior: + +- `/global/health` returned a healthy payload with version `1.14.41`. +- `/session/status` returned `{}` while idle. +- During an attached `opencode run ... --attach http://127.0.0.1:<port>`, `/session/status` returned the same authoritative `sessionID` with `{ "type": "busy" }`. + +### Title behavior + +Title semantics were probed with: + +```bash +opencode run "Reply with exactly: opencode-title-one" --format json --dangerously-skip-permissions --title probe-title-one +opencode run "Reply with exactly: opencode-title-two" --format json --dangerously-skip-permissions --session <sessionId> --title probe-title-two +opencode session --help +``` + +Observed title behavior: + +- The resumed run kept the same `sessionID`. +- The stored database title remained `probe-title-one`. +- `opencode session --help` only exposed `list` and `delete`; no rename subcommand was present in the tested mode. + +### OpenCode allowed behavior + +- Canonical OpenCode identity is the authoritative `sessionID`. +- Busy or restore state may only be promoted from the control surface or the canonical DB/session events. +- Titles are metadata and do not replace session identity. diff --git a/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md new file mode 100644 index 000000000..92eb37188 --- /dev/null +++ b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md @@ -0,0 +1,210 @@ +# Fresh Agent Platform Test Plan + +The agreed testing strategy still holds after reconciling it against [2026-04-18-fresh-agent-platform.md](/home/user/code/freshell/.worktrees/fresh-agent-platform/docs/plans/2026-04-18-fresh-agent-platform.md:1). The plan expands the interaction surface beyond the earlier high-level summary, but it does not require paid APIs, external infrastructure, or manual validation. The main adjustment is emphasis: acceptance has to be led by the real migration and user-facing surfaces in this order: persisted layout/settings migration, remote snapshot and session-directory projection, fresh-agent WS and HTTP transport, rendered shared pane behavior, then browser flows for create/resume/fork/mobile. + +## Harness requirements + +- `fresh-agent-route-harness` + What it does: extends the existing `read-model-route-harness` / `supertest` route coverage to mount `/api/fresh-agent/threads/...` routes with revision-aware snapshot, page, and turn-body handlers. + Exposes: HTTP status/body assertions, revision conflict injection, lane/scheduler observation, route call logs. + Estimated complexity: low-medium. + Tests depending on it: 5, 6, 7. + +- `fresh-agent-ws-harness` + What it does: extends the existing `protocol-harness` / `WsHandler` integration setup so tests can send and observe `freshAgent.*` messages alongside legacy terminal traffic. + Exposes: ordered outbound WS messages, adapter call capture, reconnect and lost-session simulation, ready-handshake transcript. + Estimated complexity: medium. + Tests depending on it: 4, 8, 9, 15. + +- `adapter-fixture-harness` + What it does: loads recorded Claude ledger fixtures and Codex app-server fixtures through the real normalization path, then exposes normalized snapshots/pages/bodies to server and client tests. + Exposes: deterministic provider fixtures, normalized thread snapshots, extension payloads, capability flags, fork/worktree/subagent metadata. + Estimated complexity: medium. + Tests depending on it: 10, 11, 12, 13, 14, 16. + +- `rendered-fresh-agent-app` + What it does: extends the existing RTL app and pane harnesses so a `fresh-agent` pane can be rendered with the real reducers, tabs/panes persistence, fresh-agent store, and context-menu wiring. + Exposes: rendered transcript/composer/banners, tab and pane state, persisted layout state, outbound WS/API calls. + Estimated complexity: medium. + Tests depending on it: 1, 2, 3, 8, 9, 12, 13, 14. + +- `playwright-fresh-agent-fixtures` + What it does: extends the existing Playwright `freshellPage`/`harness` fixtures to seed migrated browser storage and provider fixtures for Freshclaude and Freshcodex browser flows. + Exposes: real browser UI, screenshots/snapshots, isolated server info, browser storage seeding, harness state inspection. + Estimated complexity: medium. + Tests depending on it: 15, 16, 17, 18. + +## Test plan + +1. **Name:** Reloading a saved Freshclaude tab migrates `agent-chat` storage to `fresh-agent` without losing the pane, settings, or resume identity + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** Browser storage contains a combined layout payload with `kind: 'agent-chat'`, legacy `settings.agentChat`, pane titles, and a matching tab resume fallback. + - **Actions:** Run the real storage migration; bootstrap the app from the migrated browser storage; render the restored tab and pane. + - **Expected outcome:** Per the implementation plan sections `Steady-State Product Behavior`, `Contracts And Invariants`, and Task 1, the restored pane renders as `kind: 'fresh-agent'`, preserves `sessionType: 'freshclaude'`, keeps the existing pane/tab titles and resume identity, and does not clear unrelated Freshell browser state. Primary assertions are the rendered pane shell and persisted layout payload; supporting assertions may inspect the migrated JSON written back to the real storage keys. + - **Interactions:** `src/store/storage-migration.ts`, `src/store/persistedState.ts`, `src/store/paneTypes.ts`, `src/components/TabContent.tsx`, settings bootstrap. + +2. **Name:** Cross-tab hydrate preserves the local canonical resume identity when a remote snapshot still carries the older session id + - **Type:** regression + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** Local store already holds a `fresh-agent` pane with canonical `resumeSessionId`; an incoming persisted layout broadcast contains the same pane id with an older resume id and matching session family. + - **Actions:** Feed the remote persisted layout through the real cross-tab sync path. + - **Expected outcome:** Per Task 1 and the plan invariant `runtime readers must accept both legacy agent-chat persisted data and the new fresh-agent shape until every ... hydrate path has been switched`, the hydrated layout keeps the local canonical resume id on the pane and tab fallback metadata while still applying non-conflicting remote layout updates. Primary assertions are the post-hydrate pane/tab state visible to the app. + - **Interactions:** `src/store/crossTabSync.ts`, `src/store/persistControl.ts`, pane hydration, tab merge. + +3. **Name:** Remote tab snapshots round-trip a `fresh-agent` pane back into a reopenable tab with the same session identity and pane kind + - **Type:** integration + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A tab registry record or layout snapshot exists for a remote device with a rich agent pane, pane title metadata, and a session locator. + - **Actions:** Serialize the open tab via the real snapshot path; hydrate it through the real `TabsView` reopen flow; open the restored tab in the client. + - **Expected outcome:** Per `Remote layout snapshots and tab registry records must serialize fresh-agent, not agent-chat`, the reopened tab builds a `fresh-agent` pane, keeps `sessionType`, restores the session locator, and renders the right pane label/icon instead of falling back to picker or terminal mode. Primary assertions are the reopened tab/pane behavior in the rendered UI. + - **Interactions:** `server/agent-api/layout-store.ts`, `server/tabs-registry/types.ts`, `src/components/TabsView.tsx`, `src/lib/tab-registry-snapshot.ts`. + +4. **Name:** `freshAgent.create` routes to the adapter selected by `sessionType` while terminal WS traffic remains unchanged + - **Type:** integration + - **Disposition:** new + - **Harness:** `fresh-agent-ws-harness` + - **Preconditions:** The WS handler is mounted with a provider registry containing at least `freshclaude` and `freshcodex` adapters plus a terminal registry. + - **Actions:** Open a WS connection; send `freshAgent.create` for `freshcodex`; then send a normal `terminal.create`. + - **Expected outcome:** Per Task 2, the fresh-agent create call is dispatched to the Codex adapter chosen by `sessionType`, emits fresh-agent namespaced responses, and does not alter or intercept the terminal create flow. Primary assertions are the ordered outbound WS messages and adapter call log. + - **Interactions:** `server/ws-handler.ts`, `server/fresh-agent/runtime-manager.ts`, provider registry, existing terminal WS envelopes. + +5. **Name:** Fresh-agent thread routes reject stale revisions instead of serving mixed snapshot and body data + - **Type:** integration + - **Disposition:** new + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** A thread exists at revision `N`; the route harness can serve snapshot/page/body reads and inject stale revision conditions. + - **Actions:** Request the thread snapshot, turn page, and turn body with revision `N-1`; repeat with revision `N`. + - **Expected outcome:** Per Task 2 and the invariant `Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another`, stale requests return `409` with the stale-revision code, while current-revision requests succeed and return the matching revision. Primary assertions are HTTP status and JSON payloads. + - **Interactions:** `shared/read-models.ts`, scheduler lane selection, fresh-agent router, revision handling. + +6. **Name:** Fresh-agent read-model routes stay lane-aware and do not regress visible-first route discipline + - **Type:** invariant + - **Disposition:** extend + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** The route harness is recording scheduled lane events for bootstrap, session directory, and fresh-agent thread reads. + - **Actions:** Fetch bootstrap, session directory, and fresh-agent thread routes with `critical`, `visible`, and `background` priorities. + - **Expected outcome:** Per the existing visible-first acceptance contract and Task 2, fresh-agent thread reads use the declared lane, do not force forbidden session-directory pre-ready routes, and preserve the scheduler event ordering already required elsewhere in the app. Primary assertions are scheduler event logs and route transcripts. + - **Interactions:** read-model scheduler, visible-first fixtures, bootstrap router, fresh-agent router. + +7. **Name:** Posting session metadata updates changes `sessionType` without clobbering the stored derived title + - **Type:** regression + - **Disposition:** extend + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** Session metadata store already contains `derivedTitle` for a Codex session. + - **Actions:** Call the real session metadata API with `{ provider: 'codex', sessionId, sessionType: 'freshcodex' }`; then read back the stored entry and session-directory projection. + - **Expected outcome:** Per Task 5 and the invariant `Session metadata remains keyed by provider:sessionId; updating sessionType must keep derivedTitle`, the session now reports `sessionType: 'freshcodex'` while the prior derived title remains intact in both storage and projection. Primary assertions are API response and projected session-directory JSON. + - **Interactions:** `server/session-metadata-store.ts`, `server/sessions-router.ts`, `server/session-directory/projection.ts`, index refresh. + +8. **Name:** A `fresh-agent` pane reconnects after lost-session transport errors by surfacing the explicit session-lost state and reloading through the fresh-agent transport + - **Type:** scenario + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` plus `fresh-agent-ws-harness` + - **Preconditions:** A rendered `fresh-agent` pane is attached to an active thread; the WS harness can inject a lost-session error and a subsequent successful resume/snapshot. + - **Actions:** Deliver a fresh-agent lost-session error; trigger the pane’s retry or reconnect action; deliver the resumed snapshot/page stream. + - **Expected outcome:** Per Task 6 and Task 7, the pane shows a clear user-facing lost-session state, retry uses the fresh-agent transport rather than legacy `sdk.*`, and a successful retry restores the thread instead of degrading to a terminal or blank pane. Primary assertions are rendered error/recovery states and outbound WS/API calls. + - **Interactions:** `src/lib/ws-client.ts`, `src/lib/fresh-agent-ws.ts`, `src/store/freshAgentThunks.ts`, pane-level recovery UI. + +9. **Name:** The normalized fresh-agent client store merges live and durable updates by thread locator and revision without duplicating turns + - **Type:** invariant + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** The client store contains a thread locator, an initial snapshot, and a later live delta plus durable page for the same revision family. + - **Actions:** Dispatch the real fresh-agent snapshot, page, and body handlers in the order expected during resume and live streaming. + - **Expected outcome:** Per Task 6 and the architecture section `Shared normalized read model`, the store keys the thread by runtime locator, retains stable turn/item ids, and renders one transcript with no duplicate turns/items when live and durable sources overlap. Primary assertions are rendered transcript order and user-visible de-duplication. + - **Interactions:** `src/store/freshAgentSlice.ts`, `src/store/freshAgentThunks.ts`, read-model hydration, transcript rendering. + +10. **Name:** Claude adapter restores one canonical thread from ledger-backed durable history plus live stream state + - **Type:** integration + - **Disposition:** new + - **Harness:** `adapter-fixture-harness` + - **Preconditions:** Claude ledger fixtures cover durable backlog, live stream overlap, question state, approval state, and model/permission metadata. + - **Actions:** Load the fixtures through the real Claude fresh-agent adapter; request snapshot, page, and body data for the same thread. + - **Expected outcome:** Per Task 3 and the architecture section `Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved`, the normalized snapshot contains one canonical thread, preserves questions/approvals/model settings, and exposes provider-native detail as extension data rather than flattening it away. Primary assertions are normalized snapshot/page/body outputs from the adapter harness. + - **Interactions:** `server/fresh-agent/adapters/claude/*`, `server/sdk-bridge.ts`, `server/agent-timeline/*`. + +11. **Name:** Codex adapter normalizes fork, worktree, review, token, and child-thread metadata into the shared fresh-agent model + - **Type:** integration + - **Disposition:** new + - **Harness:** `adapter-fixture-harness` + - **Preconditions:** Codex app-server fixtures include raw rich-session events with fork lineage, worktree info, review/diff references, token summaries, and subagent children. + - **Actions:** Load the fixtures through the real Codex adapter and request snapshot/page/body data. + - **Expected outcome:** Per Task 4 and `Freshcodex rich panes use the Codex app-server as the source of truth`, the normalized thread advertises the correct capabilities, exposes worktree and child-thread refs, and keeps provider-specific extensions for Codex-only metadata. Primary assertions are the normalized shared-model payloads. + - **Interactions:** `server/coding-cli/codex-app-server/*`, `server/fresh-agent/adapters/codex/*`, provider extension payloads. + +12. **Name:** The shared fresh-agent pane shell shows provider-specific capabilities without changing the core transcript, composer, or banner affordances + - **Type:** scenario + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** One normalized Freshclaude thread fixture and one normalized Freshcodex thread fixture are available with differing capability flags. + - **Actions:** Render the shared `FreshAgentView` with the Claude thread, then with the Codex thread; activate capability-backed controls such as interrupt, fork, and approval/question actions where available. + - **Expected outcome:** Per Task 7 and `Existing Freshclaude UX stays intact unless the new shared shell makes it stronger`, both providers render the same shell structure, Claude-only or Codex-only actions appear only when their capability flags permit them, and activating those controls produces the expected user-visible state changes or outbound actions. Primary assertions are the rendered controls and their activation effects. + - **Interactions:** `src/components/fresh-agent/*`, `src/lib/fresh-agent-capabilities.ts`, context menus, shared session rendering primitives. + +13. **Name:** Freshclaude input history survives the architecture cutover and remains scoped to the pane + - **Type:** regression + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A Freshclaude `fresh-agent` pane is rendered with a composer bound to a stable pane id. + - **Actions:** Send multiple prompts, navigate history with ArrowUp/ArrowDown, reload or remount the pane, and reopen history. + - **Expected outcome:** Per Task 7 and the requirement to preserve current Freshclaude behavior, the composer recalls sent prompts in order, preserves draft behavior, persists history across reload/remount, and keeps the history isolated to the pane id rather than global session state. Primary assertions are the rendered composer value and persisted input-history storage key contents. + - **Interactions:** `src/lib/input-history-store.ts`, composer state, pane persistence. + +14. **Name:** Fresh-agent context menus target the migrated pane/session surfaces and keep resume-command, diff, and copy actions working + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A rendered fresh-agent transcript contains tool input, diff content, and a resumable session reference; tabs and panes are present in the real menu-building context. + - **Actions:** Open the context menu on transcript text, tool input, diff content, and the pane/tab chrome; activate the copy-resume-command and agent-content copy actions. + - **Expected outcome:** Per Task 7 and Task 8, the menu resolves targets against `fresh-agent` panes rather than `agent-chat`, exposes the right copy/resume items, and dispatches the expected action for each activated item. Primary assertions are rendered menu items and resulting clipboard/action calls. + - **Interactions:** `src/components/context-menu/menu-defs.ts`, session refs, resume-command helpers, transcript DOM attributes. + +15. **Name:** Browser user can create and resume Freshcodex with visible worktree and fork metadata in the shared pane + - **Type:** scenario + - **Disposition:** new + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright server fixture has Codex rich-session fixtures available and Freshcodex enabled in the pane picker. + - **Actions:** Open the pane picker, create a Freshcodex pane, allow the thread to load, navigate away and back or reload to resume it, then activate the fork/worktree UI affordances. + - **Expected outcome:** Per the user goal and Tasks 4, 6, and 7, the real browser UI shows a Freshcodex pane, preserves the resumed thread after reload, and visibly surfaces worktree/fork metadata through the shared shell. Primary assertions are browser-visible controls, labels, and thread content; supporting assertions may read harness state. + - **Interactions:** pane picker, fresh-agent transport, Codex adapter, browser storage persistence, shared pane shell. + +16. **Name:** Browser user can restore Freshclaude and still see approval and question banners after the migration + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright browser storage or server fixture seeds a persisted Freshclaude rich pane with outstanding approval and question state. + - **Actions:** Load the app, open the restored pane, respond to the approval/question controls, and reload the page. + - **Expected outcome:** Per `Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair`, the restored pane appears automatically, banners are visible and actionable, and their resolved state persists across reload. Primary assertions are the browser-visible alert/banner content and action effects. + - **Interactions:** persisted layout migration, Claude adapter, fresh-agent client store, banner actions. + +17. **Name:** Mobile browser flows keep the fresh-agent shell usable without regressing tab and sidebar navigation + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright viewport is mobile-sized and the app starts with at least one rich agent tab plus standard tab strip controls. + - **Actions:** Open the mobile tab switcher, create or switch to a fresh-agent tab, open the sidebar/history surface, and return to the pane. + - **Expected outcome:** Per the implementation plan requirement `Mobile and sidebar behavior remain first-class requirements`, the mobile tab strip, tab switcher, sidebar open/close controls, and fresh-agent pane remain operable and visually coherent; the user can reach and return from the sidebar/history flows without losing the active rich pane. Primary assertions are browser-visible controls and resulting navigation state. + - **Interactions:** `src/components/MobileTabStrip.tsx`, `src/components/Sidebar.tsx`, `src/components/HistoryView.tsx`, rich pane mount/unmount behavior. + +18. **Name:** Full targeted verification replaces legacy `agent-chat` browser proofs with fresh-agent browser proofs before deletion + - **Type:** regression + - **Disposition:** new + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Replacement browser specs exist for the flows currently covered by `agent-chat.spec.ts`, `agent-chat-input-history.spec.ts`, `pane-activity-indicator.spec.ts`, and the rich-pane portions of `tab-management.spec.ts`. + - **Actions:** Run the fresh-agent browser specs that cover create, resume, input history, pane activity, and tab restoration; compare their covered user-visible behaviors against the legacy spec inventory before any legacy rich-pane spec is deleted or renamed away. + - **Expected outcome:** Per Task 8 and `Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere`, the fresh-agent browser suite proves the same user-visible behaviors before legacy `agent-chat` browser coverage is removed. Primary assertions are passing browser scenarios and a one-to-one coverage mapping in the renamed replacement specs. + - **Interactions:** Playwright fixtures, pane activity indicators, input history, restored tabs, spec migration inventory. + +## Coverage summary + +- Covered action space: + storage migration on startup; local settings migration; persisted layout parsing and hydration; cross-tab persisted-layout broadcast handling; remote tab snapshot serialization and reopen; session metadata POST; session-directory projection refresh; fresh-agent WebSocket create/resume/reconnect/lost-session handling; fresh-agent thread snapshot/page/body HTTP reads; Claude and Codex adapter normalization; shared fresh-agent transcript/composer/banner/diff/context-menu actions; pane picker create flows; browser restore flows; mobile tab/sidebar navigation. + +- Explicitly excluded per the agreed strategy: + live external Claude/Codex/OpenCode processes, real paid provider APIs, production-only worktree/review backends outside the repo’s existing fixtures, and manual visual review. + +- Risks carried by those exclusions: + true subprocess timing issues, upstream protocol drift, or production-only review/worktree behaviors could still appear after local verification. The strongest practical mitigation here is fixture-backed adapter coverage plus real browser/UI coverage against the repo’s own transport and persistence paths, which this plan makes the acceptance gate. diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md new file mode 100644 index 000000000..f4ba5b95a --- /dev/null +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -0,0 +1,915 @@ +# Fresh Agent Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a shared `fresh-agent` platform that powers `freshclaude` and `freshcodex` from one architecture, preserves existing Freshclaude behavior and saved state, and leaves a clean adapter seam for `freshopencode` later without another rewrite. + +**Architecture:** Cut over directly from the current `agent-chat` domain to a new `fresh-agent` domain that separates user-facing `sessionType` from runtime `provider`. Reuse Claude’s existing durable/live ledger stack and the existing Codex app-server runtime, normalize both into one read model plus one shared UI shell, and migrate every persistence/restore surface up front so current users keep their tabs, settings, history, and remote snapshots. + +**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model scheduler, Claude SDK bridge, Codex app-server runtime/client, Vitest, Testing Library, Playwright browser e2e. + +--- + +## Why The Previous Plan Would Fail + +- It correctly chose the target architecture, but it did not fully cover the persistence and restore surfaces that still encode `agent-chat`. An executor following it would get partway through the cutover and then discover breakage in local storage versioning, remote tab snapshots, tab registry history, and sidebar resume behavior. +- It did not account for [`src/store/storage-migration.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/storage-migration.ts:1), which currently hard-clears persisted browser state on incompatible version changes. A naive schema bump would erase exactly the saved Freshclaude tabs and settings the user asked to preserve. +- It did not include the remote snapshot and restore path through [`server/agent-api/layout-store.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/agent-api/layout-store.ts:1), [`server/tabs-registry/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/tabs-registry/types.ts:1), [`src/store/tabRegistryTypes.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/tabRegistryTypes.ts:1), and [`src/components/TabsView.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabsView.tsx:1). Without those, reopened remote tabs would still serialize and hydrate `agent-chat`. +- It did not include the layout bootstrap path through [`src/components/TabContent.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabContent.tsx:1), [`src/lib/tab-directory-preference.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/lib/tab-directory-preference.ts:1), and [`src/store/paneTreeValidation.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/paneTreeValidation.ts:1). Those still special-case `agent-chat`. +- It sequenced the persistence cutover too early. If an executor migrates stored panes from `agent-chat` to `fresh-agent` before `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot parsing can read the new shape, the next reload or cross-tab hydrate will strand rich panes as unknown content. +- It did not call out [`src/store/selectors/sidebarSelectors.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/selectors/sidebarSelectors.ts:1) and related session metadata helpers, which are where `provider` and `sessionType` semantics get merged for history/sidebar rendering. Missing them would reintroduce the wrong identity model after the store cutover. +- It did not call out [`server/coding-cli/session-indexer.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/session-indexer.ts:1) and [`server/coding-cli/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/types.ts:1), which are where stored session metadata, derived titles, and indexed Codex runtime metadata get merged before the session directory and sidebar consume them. Leaving that seam out would make the migration look complete in storage while the user-visible projections stayed wrong. +- It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags. +- It did not include the existing browser specs, Vitest e2e flows, context-menu tests, visible-first perf fixtures, and MCP help text that still hard-code `agent-chat` or `sdk.*`. An executor could finish the product code, hit the repo-wide verification gate, and then discover a second migration hidden in the test/tooling surface. +- It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery. +- It still leaned too hard toward rebuilding the UI layer from scratch. The repo already contains reusable shared rendering pieces in `src/components/session/*` plus reusable diff and settings primitives. A plan that does not explicitly direct the executor to reuse and promote those pieces risks violating the user’s “reuse as much as possible” requirement and burning time on unnecessary rewrites. +- Its cleanup section was too deletion-oriented. Several files and tests listed as “modify/delete” are not dead weight; they are the current regression net for restore hydration, split-pane remounts, browser/mobile behavior, MCP tool help text, and visible-first performance contracts. Treating them as delete-first would cause avoidable backtracking and test dilution. + +## Steady-State Product Behavior + +- Rich panes use `kind: 'fresh-agent'`, not `kind: 'agent-chat'`. +- `freshclaude`, `freshcodex`, and hidden `kilroy` all come from one fresh-agent registry. +- `provider` means runtime family (`claude`, `codex`, later `opencode`). +- `sessionType` means user-facing identity (`freshclaude`, `freshcodex`, `kilroy`, later `freshopencode`). +- Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair. +- Raw CLI terminals remain separate pane types. Rich panes never silently degrade into terminal scraping. +- Freshcodex rich panes use the Codex app-server as the source of truth and surface fork, diff/review, worktree, child-thread, and token/context metadata when the runtime exposes them. +- OpenCode is not shipped here, but the registry, adapter interface, and normalized model already admit it without Claude- or Codex-specific assumptions leaking into shared code. + +## Contracts And Invariants + +### Naming and persistence + +- The final domain name is `fresh-agent`, not `agent-chat`. +- Persisted pane/layout data must migrate existing `agent-chat` leaves to `fresh-agent` leaves. +- Browser storage migration must preserve existing Freshell auth, layout, and settings data; do not solve this by clearing local storage. +- Server/local settings must migrate `agentChat` to `freshAgent` while continuing to read legacy input during rollout. +- Remote layout snapshots and tab registry records must serialize `fresh-agent`, not `agent-chat`. +- Session metadata remains keyed by `provider:sessionId`; updating `sessionType` must keep `derivedTitle`. + +### Registry and adapters + +- One declarative registry owns labels, icons, settings visibility/defaults, runtime provider, and feature flags for `freshclaude`, `freshcodex`, `kilroy`, and disabled `freshopencode`. +- Runtime adapters own `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval`, `listThreads`, `getSnapshot`, `getTurnPage`, `getTurnBody`, and capability-backed workspace actions. +- Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved, not discarded. +- Codex runtime reuses `server/coding-cli/codex-app-server/*`; extend that stack instead of duplicating it. + +### Read model and UI + +- All shared UI reads normalized fresh-agent data first and provider extensions second. +- Normalized entities have stable ids for thread, turn, item, approval, question, diff, artifact, child thread, and worktree references. +- Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another. +- Existing Freshclaude UX stays intact unless the new shared shell makes it stronger. +- Mobile and sidebar behavior remain first-class requirements, not follow-up cleanup. +- During the migration tasks, runtime readers must accept both legacy `agent-chat` persisted data and the new `fresh-agent` shape until every local bootstrap, cross-tab hydrate, and remote snapshot path has been switched. + +## File Structure + +### Create + +- `shared/fresh-agent.ts` +- `server/fresh-agent/runtime-adapter.ts` +- `server/fresh-agent/provider-registry.ts` +- `server/fresh-agent/runtime-manager.ts` +- `server/fresh-agent/router.ts` +- `server/fresh-agent/adapters/claude/adapter.ts` +- `server/fresh-agent/adapters/claude/normalize.ts` +- `server/fresh-agent/adapters/codex/adapter.ts` +- `server/fresh-agent/adapters/codex/normalize.ts` +- `src/lib/fresh-agent-registry.ts` +- `src/lib/fresh-agent-capabilities.ts` +- `src/lib/fresh-agent-ws.ts` +- `src/store/freshAgentTypes.ts` +- `src/store/freshAgentSlice.ts` +- `src/store/freshAgentThunks.ts` +- `src/components/fresh-agent/FreshAgentView.tsx` +- `src/components/fresh-agent/FreshAgentTranscript.tsx` +- `src/components/fresh-agent/FreshAgentComposer.tsx` +- `src/components/fresh-agent/FreshAgentSidebar.tsx` +- `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` +- `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` +- `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- `test/fixtures/fresh-agent/claude/*` +- `test/fixtures/fresh-agent/codex/*` +- `test/e2e-browser/specs/fresh-agent.spec.ts` +- `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` + +### Modify + +- `shared/ws-protocol.ts` +- `shared/read-models.ts` +- `shared/settings.ts` +- `server/config-store.ts` +- `server/index.ts` +- `server/ws-handler.ts` +- `server/platform-router.ts` +- `server/sdk-bridge.ts` +- `server/sdk-bridge-types.ts` +- `server/agent-timeline/*` +- `server/coding-cli/codex-app-server/protocol.ts` +- `server/coding-cli/codex-app-server/client.ts` +- `server/coding-cli/codex-app-server/runtime.ts` +- `server/coding-cli/codex-app-server/launch-planner.ts` +- `server/coding-cli/providers/codex.ts` +- `server/session-directory/*` +- `server/sessions-router.ts` +- `server/session-metadata-store.ts` +- `server/agent-api/layout-store.ts` +- `server/tabs-registry/types.ts` +- `src/store/paneTypes.ts` +- `src/store/panesSlice.ts` +- `src/store/persistedState.ts` +- `src/store/persistMiddleware.ts` +- `src/store/crossTabSync.ts` +- `src/store/storage-migration.ts` +- `src/store/store.ts` +- `src/store/persistControl.ts` +- `src/store/tabsSlice.ts` +- `src/store/settingsSlice.ts` +- `src/store/settingsThunks.ts` +- `src/store/browserPreferencesPersistence.ts` +- `src/store/paneTreeValidation.ts` +- `src/store/tabRegistryTypes.ts` +- `src/store/selectors/sidebarSelectors.ts` +- `src/lib/session-type-utils.ts` +- `src/lib/derivePaneTitle.ts` +- `src/lib/pane-title.ts` +- `src/lib/pane-activity.ts` +- `src/lib/session-utils.ts` +- `src/lib/input-history-store.ts` +- `src/lib/tab-directory-preference.ts` +- `src/lib/tab-registry-snapshot.ts` +- `src/lib/ws-client.ts` +- `src/lib/api.ts` +- `src/lib/agent-chat-utils.ts` +- `src/lib/agent-chat-types.ts` +- `src/components/session/MessageBubble.tsx` +- `src/components/session/ToolCallBlock.tsx` +- `src/components/panes/PaneContainer.tsx` +- `src/components/panes/PanePicker.tsx` +- `src/components/Sidebar.tsx` +- `src/components/HistoryView.tsx` +- `src/components/TabContent.tsx` +- `src/components/TabsView.tsx` +- `src/components/context-menu/ContextMenuProvider.tsx` +- `src/components/context-menu/context-menu-constants.ts` +- `src/components/context-menu/context-menu-types.ts` +- `src/components/context-menu/context-menu-utils.ts` +- `src/components/context-menu/menu-defs.ts` +- `src/components/icons/PaneIcon.tsx` +- `src/components/TabBar.tsx` +- `src/components/TabSwitcher.tsx` +- `src/components/MobileTabStrip.tsx` +- `src/components/SettingsView.tsx` +- `src/components/settings/WorkspaceSettings.tsx` +- `src/components/agent-chat/DiffView.tsx` +- `server/mcp/freshell-tool.ts` +- `docs/index.html` +- `test/unit/client/store/panesPersistence.test.ts` +- `test/unit/server/agent-layout-schema.test.ts` +- `test/unit/server/tabs-registry/types.test.ts` +- `test/integration/server/settings-api.test.ts` +- `test/integration/server/tabs-registry-store.persistence.test.ts` +- `test/integration/server/session-directory-router.test.ts` + +### Delete Only After Porting Behavior And Coverage + +- `src/store/agentChatSlice.ts` +- `src/store/agentChatThunks.ts` +- `src/store/agentChatTypes.ts` +- `src/lib/sdk-message-handler.ts` +- legacy `src/components/agent-chat/*` files that are provably dead after their behavior has been moved into `src/components/fresh-agent/*` or promoted shared primitives +- renamed or superseded test files only after the replacement tests cover the same restore, split-pane, session-lost, input-history, mobile, context-menu, and perf behaviors +- any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after the new transport, persistence, and coverage are all green + +## Strategy Gate + +The right path is a direct cutover to the final architecture, not another “genericize `sdk.*` later” detour. The repo already contains the two foundations this work must respect: + +- Claude has durable/live merge and restore semantics in `server/sdk-bridge.ts` and `server/agent-timeline/*`. +- Codex already has a shared app-server runtime/client/planner in `server/coding-cli/codex-app-server/*`. +- The client already has reusable generalized rendering and event primitives in `src/components/session/*`, `src/components/agent-chat/DiffView.tsx`, `src/lib/input-history-store.ts`, and `src/lib/coding-cli-types.ts`. + +The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings/restore surfaces first, and then lands one transport, one read model, one store, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives. + +### Task 1: Rename the domain to `fresh-agent` and migrate local/server persistence without data loss + +**Files:** +- Create: `shared/fresh-agent.ts` +- Create: `src/lib/fresh-agent-registry.ts` +- Modify: `shared/settings.ts` +- Modify: `server/config-store.ts` +- Modify: `server/platform-router.ts` +- Modify: `src/store/settingsSlice.ts` +- Modify: `src/store/settingsThunks.ts` +- Modify: `src/store/browserPreferencesPersistence.ts` +- Modify: `src/store/storage-migration.ts` +- Modify: `src/store/paneTypes.ts` +- Modify: `src/store/panesSlice.ts` +- Modify: `src/store/persistedState.ts` +- Modify: `src/store/persistMiddleware.ts` +- Modify: `src/store/crossTabSync.ts` +- Modify: `src/store/paneTreeValidation.ts` +- Modify: `src/lib/agent-chat-utils.ts` +- Modify: `src/lib/agent-chat-types.ts` +- Modify: `src/lib/pane-title.ts` +- Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/TabContent.tsx` +- Modify: `src/lib/tab-registry-snapshot.ts` +- Test: `test/unit/shared/fresh-agent-registry.test.ts` +- Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` +- Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts` +- Test: `test/unit/client/store/crossTabSync.test.ts` +- Test: `test/unit/client/store/panesPersistence.test.ts` +- Test: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx` +- Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` +- Test: `test/integration/server/settings-api.test.ts` +- Test: `test/unit/server/agent-layout-schema.test.ts` +- Test: `test/unit/client/store/tabsSlice.merge.test.ts` +- Test: `test/integration/server/platform-api.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Add tests that pin the migration and registry rules: + +```ts +it('migrates persisted agent-chat panes to fresh-agent panes', () => { + const parsed = parsePersistedPanesRaw(JSON.stringify({ + version: 6, + layouts: { + tab_1: { + type: 'leaf', + id: 'pane_1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + })) + expect(findLeafContent(parsed!.layouts.tab_1)).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) +}) + +it('migrates legacy settings.agentChat to settings.freshAgent', () => { + const settings = resolveServerSettings({ + agentChat: { defaultPlugins: ['/tmp/plugin'], providers: { freshclaude: { defaultModel: 'x' } } }, + } as any) + expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) +}) + +it('does not clear freshell layout storage during the fresh-agent migration', () => { + // Use the real layout-storage key from storage-migration.ts in the implementation test. + // The literal below is illustrative only. + localStorage.setItem('freshell.layout.v3', '{"version":3}') + runStorageMigration() + expect(localStorage.getItem('freshell.layout.v3')).toBe('{"version":3}') +}) + +it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { + expect(resolveFreshAgentType('kilroy')).toMatchObject({ runtimeProvider: 'claude', hidden: true }) +}) + +it('hydrates a persisted fresh-agent pane without falling back to an unknown pane kind', () => { + const content = getLeafContentFromHydratedLayout({ + type: 'leaf', + id: 'pane_1', + content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', createRequestId: 'req-1', status: 'idle' }, + }) + expect(content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) +}) + +it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-agent pane', () => { + const result = protectCanonicalPaneResumeIdentity( + buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'remote-id' }), + buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'local-id' }), + ) + expect(getLeafContent(result)?.resumeSessionId).toBe('local-id') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` +Expected: FAIL because the registry and migrations do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Implement the shared vocabulary and migrations: + +- `FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode'` +- `FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode'` +- registry entries for `freshclaude`, `freshcodex`, hidden `kilroy`, and disabled `freshopencode` +- persisted layout migration from `kind: 'agent-chat'` to `kind: 'fresh-agent'` +- server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input +- storage migration that preserves saved Freshell state instead of clearing it by rewriting the persisted rich-pane/settings payloads before any incompatible-version clear path runs; do not preserve data by skipping migration or suppressing future version bumps +- pane content shape that stores `sessionType` explicitly instead of overloading `provider` +- compatibility readers in `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot helpers so fresh-agent layouts can boot before the legacy client state is removed +- migration of browser preference and input-history surfaces that currently derive behavior from legacy `agent-chat` pane data + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Refactor compatibility to one place only: + +- legacy `agent-chat` parsing belongs in persisted/settings migration code +- runtime readers accept `fresh-agent` first and tolerate legacy `agent-chat` only at bootstrap boundaries +- `agent-chat` helper modules become thin compatibility exports or are queued for removal + +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts +git commit -m "refactor: add fresh agent registry and settings vocabulary" + +git add src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts +git commit -m "refactor: migrate fresh agent persistence surfaces" + +git add src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx +git commit -m "refactor: add fresh agent bootstrap compatibility" +``` + +### Task 2: Build the shared fresh-agent transport and normalized read-model contract + +**Files:** +- Create: `server/fresh-agent/runtime-adapter.ts` +- Create: `server/fresh-agent/provider-registry.ts` +- Create: `server/fresh-agent/runtime-manager.ts` +- Create: `server/fresh-agent/router.ts` +- Modify: `shared/ws-protocol.ts` +- Modify: `shared/read-models.ts` +- Modify: `server/ws-handler.ts` +- Modify: `server/index.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` +- Test: `test/unit/server/fresh-agent/router.test.ts` +- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Write server tests that prove the final contract: + +```ts +it('routes freshAgent.create through the adapter selected by sessionType', async () => { + expect(adapter.create).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex' })) +}) + +it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => { + const response = await request(app).get('/api/fresh-agent/threads/codex/thread-1/turns/turn-9?revision=4') + expect(response.status).toBe(409) + expect(response.body.code).toBe('STALE_THREAD_REVISION') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: FAIL because the fresh-agent transport does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Implement: + +- provider registry lookup from `sessionType` +- runtime manager operations: `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval` +- normalized snapshot/page/body read-model types +- `freshAgent.*` WS messages and `/api/fresh-agent/threads/...` routes, using a dedicated fresh-agent namespace instead of overloading terminal envelopes +- explicit error taxonomy for runtime unavailable, stale revision, unsupported capability, and lost session + +Keep terminal WebSocket behavior untouched. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Remove fake-generic `sdk.*` transport assumptions from shared code. Keep only Claude-specific implementation details under the Claude adapter boundary. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts test/unit/visible-first/read-model-route-harness.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts shared/ws-protocol.ts shared/read-models.ts server/ws-handler.ts server/index.ts test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts +git commit -m "feat: add fresh agent transport and read models" +``` + +### Task 3: Move Claude runtime behavior behind the Claude fresh-agent adapter + +**Files:** +- Create: `test/fixtures/fresh-agent/claude/*` +- Create: `server/fresh-agent/adapters/claude/adapter.ts` +- Create: `server/fresh-agent/adapters/claude/normalize.ts` +- Modify: `server/sdk-bridge.ts` +- Modify: `server/sdk-bridge-types.ts` +- Modify: `server/agent-timeline/ledger.ts` +- Modify: `server/agent-timeline/history-source.ts` +- Modify: `server/agent-timeline/service.ts` +- Modify: `server/agent-timeline/router.ts` +- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/claude-normalize.test.ts` +- Test: `test/unit/server/fresh-agent/claude-restore-contract.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Cover the Freshclaude behaviors that must survive: + +```ts +it('merges ledger-backed restore state and live stream into one canonical snapshot', async () => { + expect(snapshot.turns.map((turn) => turn.source)).toEqual(['durable', 'live']) +}) + +it('preserves plugin defaults and mid-session model/permission changes through the claude adapter', async () => { + expect(adapter.updateSessionSettings).toHaveBeenCalledWith(expect.objectContaining({ + defaultPlugins: ['/tmp/plugin'], + model: expect.any(String), + permissionMode: 'plan', + })) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` +Expected: FAIL because the adapter does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Wrap the existing Claude stack behind the adapter: + +- preserve durable/live restore semantics +- preserve question/permission flows +- preserve model and permission-mode updates +- preserve plugin injection and token summaries +- normalize Claude block messages into shared fresh-agent items + +Do not let Claude-specific types leak back into shared contracts. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Move only real Claude implementation details under `server/fresh-agent/adapters/claude/*`; delete any top-level abstractions that only existed to make Claude look generic. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/ws-sdk-session-history-cache.test.ts test/unit/server/sdk-bridge.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/adapters/claude/adapter.ts server/fresh-agent/adapters/claude/normalize.ts server/sdk-bridge.ts server/sdk-bridge-types.ts server/agent-timeline/ledger.ts server/agent-timeline/history-source.ts server/agent-timeline/service.ts server/agent-timeline/router.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts +git commit -m "refactor: move claude runtime behind fresh agent adapter" +``` + +### Task 4: Extend the existing Codex app-server stack for rich Freshcodex sessions + +**Files:** +- Create: `test/fixtures/fresh-agent/codex/*` +- Create: `server/fresh-agent/adapters/codex/adapter.ts` +- Create: `server/fresh-agent/adapters/codex/normalize.ts` +- Modify: `server/coding-cli/codex-app-server/protocol.ts` +- Modify: `server/coding-cli/codex-app-server/client.ts` +- Modify: `server/coding-cli/codex-app-server/runtime.ts` +- Modify: `server/coding-cli/codex-app-server/launch-planner.ts` +- Modify: `server/coding-cli/providers/codex.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` +- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/codex-normalize.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Pin the rich Codex requirements without creating a duplicate client: + +```ts +it('starts fresh rich codex threads with raw events enabled', async () => { + await runtime.planCreate({ cwd: '/repo', richClient: true }) + expect(requestParams.experimentalRawEvents).toBe(true) +}) + +it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', async () => { + expect(snapshot.capabilities.fork).toBe(true) + expect(snapshot.worktrees[0]?.path).toContain('.worktrees') + expect(snapshot.childThreads[0]?.origin).toBe('subagent') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Expected: FAIL because the existing app-server layer does not yet expose rich-session events and capabilities. + +- [ ] **Step 3: Write minimal implementation** + +Extend the current Codex app-server stack instead of copying it: + +- add protocol/client support for the notifications and RPCs needed by rich Freshcodex +- keep terminal-mode behavior intact +- allow rich-pane creation and resume to request raw events and replay where required +- normalize review, diff, fork lineage, worktree, token/context, and child-thread metadata into the shared model +- keep `server/coding-cli/providers/codex.ts` focused on indexing and terminal concerns + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Make the adapter thin and keep the protocol source of truth in `server/coding-cli/codex-app-server/*`. + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts test/unit/server/coding-cli/codex-provider.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/codex/normalize.ts server/coding-cli/codex-app-server/protocol.ts server/coding-cli/codex-app-server/client.ts server/coding-cli/codex-app-server/runtime.ts server/coding-cli/codex-app-server/launch-planner.ts server/coding-cli/providers/codex.ts test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts +git commit -m "feat: add codex rich runtime support" +``` + +### Task 5: Integrate fresh-agent sessions into metadata, session directory, remote snapshots, and resume flows + +**Files:** +- Modify: `server/session-directory/projection.ts` +- Modify: `server/session-directory/service.ts` +- Modify: `server/session-directory/types.ts` +- Modify: `server/coding-cli/session-indexer.ts` +- Modify: `server/coding-cli/types.ts` +- Modify: `server/sessions-router.ts` +- Modify: `server/session-metadata-store.ts` +- Modify: `server/agent-api/layout-store.ts` +- Modify: `server/tabs-registry/types.ts` +- Modify: `src/store/tabRegistryConstants.ts` +- Modify: `src/store/tabRegistrySlice.ts` +- Modify: `src/store/tabRegistrySync.ts` +- Modify: `src/store/tabRegistryTypes.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/session-metadata.ts` +- Modify: `src/lib/session-type-utils.ts` +- Modify: `src/lib/tab-directory-preference.ts` +- Modify: `src/lib/tab-registry-snapshot.ts` +- Modify: `src/components/TabContent.tsx` +- Modify: `src/components/TabsView.tsx` +- Modify: `src/store/selectors/sidebarSelectors.ts` +- Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` +- Test: `test/unit/server/coding-cli/session-indexer.test.ts` +- Test: `test/unit/server/session-metadata-store.test.ts` +- Test: `test/integration/server/session-metadata-api.test.ts` +- Test: `test/unit/server/agent-api/layout-store.fresh-agent.test.ts` +- Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx` +- Test: `test/unit/client/lib/api.test.ts` +- Test: `test/unit/server/tabs-registry/types.test.ts` +- Test: `test/integration/server/tabs-registry-store.persistence.test.ts` +- Test: `test/integration/server/session-directory-router.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Cover the resume and snapshot contract: + +```ts +it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { + await store.set('codex', 'sess-1', { derivedTitle: 'Sticky title' }) + await request(app).post('/api/session-metadata').send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' }) + expect(await store.get('codex', 'sess-1')).toMatchObject({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' }) +}) + +it('serializes fresh-agent panes in remote layout snapshots and rehydrates them back into fresh-agent panes', () => { + expect(snapshot.panes[0]).toMatchObject({ kind: 'fresh-agent' }) + expect(restored.content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) +}) + +it('projects fresh sessionType and codex runtime metadata through the indexed session directory snapshot', async () => { + expect(projects[0]?.sessions[0]).toMatchObject({ + sessionType: 'freshcodex', + isSubagent: true, + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Expected: FAIL because fresh-agent metadata and snapshot flows are not fully projected yet. + +- [ ] **Step 3: Write minimal implementation** + +Implement projection and metadata updates so that: + +- sidebar and history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly +- resume actions rebuild `kind: 'fresh-agent'` panes +- `sessionType` updates do not clobber `derivedTitle` +- coding-cli indexing merges `sessionType`, derived titles, and Codex task metadata into the same summaries the session directory and sidebar render +- remote layout snapshots and registry records store `fresh-agent`, not `agent-chat` +- session directory carries the metadata needed for fork, worktree, and subagent badges +- tabs-registry persistence and HTTP/session-directory routes expose the same migrated shape the UI hydrates +- tab-registry constants, sync reducers, and selectors continue to round-trip the migrated `sessionType` and `fresh-agent` pane shape with no legacy `agent-chat` assumptions left behind + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem or provider fields. + +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryConstants.ts src/store/tabRegistrySlice.ts src/store/tabRegistrySync.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts +git commit -m "feat: project fresh agent sessions through metadata and snapshots" +``` + +### Task 6: Replace client state and WebSocket handling with the fresh-agent store + +**Files:** +- Create: `src/lib/fresh-agent-capabilities.ts` +- Create: `src/lib/fresh-agent-ws.ts` +- Create: `src/store/freshAgentTypes.ts` +- Create: `src/store/freshAgentSlice.ts` +- Create: `src/store/freshAgentThunks.ts` +- Modify: `src/store/store.ts` +- Modify: `src/store/persistControl.ts` +- Modify: `src/store/tabsSlice.ts` +- Modify: `src/lib/ws-client.ts` +- Modify: `src/lib/session-utils.ts` +- Modify: `src/lib/pane-activity.ts` +- Modify: `src/components/TabSwitcher.tsx` +- Test: `test/unit/client/store/freshAgentSlice.test.ts` +- Test: `test/unit/client/store/freshAgentThunks.test.ts` +- Test: `test/unit/client/lib/fresh-agent-ws.test.ts` +- Test: `test/unit/client/store/persistControl.fresh-agent.test.ts` +- Test: `test/unit/server/ws-handler-sdk.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Write client tests for the final state model: + +```ts +it('stores threads by locator and revision without duplicating live and durable items', () => { + expect(state.threads['claude:thread-1'].turnOrder).toEqual(['turn-1', 'turn-2']) +}) + +it('persists sessionType and provider separately for resume identity', () => { + expect(update.tabUpdates?.sessionMetadataByKey?.['codex:sess-1']).toMatchObject({ sessionType: 'freshcodex' }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts` +Expected: FAIL because the fresh-agent state layer does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Implement the normalized client layer: + +- thread state keyed by runtime locator +- revision-safe snapshot, page, and body hydration +- WS handling for `freshAgent.*` +- pending approvals, questions, and action errors in shared state +- capability helpers that normalize provider-backed actions into one client-facing surface for the shared shell +- resume identity helpers that work for Claude and Codex without Claude-only assumptions +- pane activity and tab switcher wiring that no longer read from `agentChat` + +Do not persist transient streaming or pending-action state. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Convert shared selectors and helpers to consume `freshAgent` state, not `agentChat`. + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/server/ws-handler-sdk.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-utils.ts src/lib/pane-activity.ts src/components/TabSwitcher.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/server/ws-handler-sdk.test.ts +git commit -m "feat: add fresh agent client state" +``` + +### Task 7: Ship the shared fresh-agent UI shell and preserve current Freshclaude behavior + +**Files:** +- Create: `src/components/fresh-agent/FreshAgentView.tsx` +- Create: `src/components/fresh-agent/FreshAgentTranscript.tsx` +- Create: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Create: `src/components/fresh-agent/FreshAgentSidebar.tsx` +- Create: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` +- Create: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` +- Create: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/panes/PanePicker.tsx` +- Modify: `src/components/Sidebar.tsx` +- Modify: `src/components/HistoryView.tsx` +- Modify: `src/components/context-menu/ContextMenuProvider.tsx` +- Modify: `src/components/context-menu/context-menu-constants.ts` +- Modify: `src/components/context-menu/context-menu-types.ts` +- Modify: `src/components/context-menu/context-menu-utils.ts` +- Modify: `src/components/context-menu/menu-defs.ts` +- Modify: `src/components/icons/PaneIcon.tsx` +- Modify: `src/components/TabBar.tsx` +- Modify: `src/components/MobileTabStrip.tsx` +- Modify: `src/components/SettingsView.tsx` +- Modify: `src/components/settings/WorkspaceSettings.tsx` +- Modify: `src/lib/derivePaneTitle.ts` +- Modify: `src/lib/pane-title.ts` +- Modify: `src/lib/pane-activity.ts` +- Modify: `src/components/session/MessageBubble.tsx` +- Modify: `src/components/session/ToolCallBlock.tsx` +- Modify: `src/components/agent-chat/DiffView.tsx` +- Modify: `src/lib/input-history-store.ts` +- Modify: `docs/index.html` +- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +- Test: `test/unit/client/components/panes/PaneContainer.test.tsx` +- Test: `test/unit/client/components/SettingsView.fresh-agent.test.tsx` +- Test: `test/unit/client/components/ContextMenuProvider.test.tsx` +- Test: `test/unit/client/components/context-menu/menu-defs.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Pin the user-visible shell: + +```tsx +it('renders the same shell for freshclaude and freshcodex while honoring capability differences', () => { + render(<FreshAgentView thread={codexThread} />) + expect(screen.getByRole('button', { name: /fork/i })).toBeVisible() + render(<FreshAgentView thread={claudeThread} />) + expect(screen.queryByRole('button', { name: /fork/i })).toBeNull() +}) + +it('preserves existing freshclaude settings, plugin controls, and question banners after migration', () => { + expect(screen.getByRole('button', { name: /send/i })).toBeEnabled() + expect(screen.getByRole('alert')).toHaveTextContent('Question') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` +Expected: FAIL because the shared UI shell does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Build the shared shell with: + +- virtualized transcript backed by normalized turns and items +- approval and question banners reused across providers +- shared composer supporting send, interrupt, fork, and capability-backed actions +- mobile drawers or sheets for secondary panes +- promotion of reusable primitives instead of duplication: adapt `src/components/session/MessageBubble.tsx`, `src/components/session/ToolCallBlock.tsx`, and `src/components/agent-chat/DiffView.tsx` into provider-agnostic building blocks wherever possible +- preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking and tools toggles +- fresh-agent-aware context menus, pane badges, and resume-command affordances with no stale `agent-chat` target assumptions + +Switch pane picker, pane container, sidebar, history, and context menus to `kind: 'fresh-agent'`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Fold or delete old `src/components/agent-chat/*` pieces only when their behavior has been moved into `src/components/fresh-agent/*` or shared primitives and the corresponding tests have been ported without coverage loss. + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu/ContextMenuProvider.tsx src/components/context-menu/context-menu-constants.ts src/components/context-menu/context-menu-types.ts src/components/context-menu/context-menu-utils.ts src/components/context-menu/menu-defs.ts src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/MobileTabStrip.tsx src/components/SettingsView.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-title.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts +git commit -m "feat: ship shared fresh agent pane shell" +``` + +### Task 8: Port remaining regression coverage, remove only provably dead code, and run the full verification gate + +**Files:** +- Modify/Delete: `src/store/agentChatSlice.ts` +- Modify/Delete: `src/store/agentChatThunks.ts` +- Modify/Delete: `src/store/agentChatTypes.ts` +- Modify/Delete: `src/components/agent-chat/*` +- Modify/Delete: `src/lib/sdk-message-handler.ts` +- Modify: `server/mcp/freshell-tool.ts` +- Modify/Rename: `test/e2e/agent-chat-*.test.tsx` +- Modify/Rename: `test/e2e/pane-activity-indicator-flow.test.tsx` +- Modify/Rename: `test/e2e/pane-header-runtime-meta-flow.test.tsx` +- Modify/Rename: `test/e2e/sidebar-click-opens-pane.test.tsx` +- Modify/Rename: `test/e2e/title-sync-flow.test.tsx` +- Modify/Rename: `test/e2e/tool-coalesce.test.tsx` +- Modify/Rename: `test/e2e-browser/specs/agent-chat.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/agent-chat-input-history.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/pane-activity-indicator.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/tab-management.spec.ts` +- Modify: `test/e2e-browser/perf/audit-contract.ts` +- Modify: `test/e2e-browser/perf/run-sample.ts` +- Modify: `test/e2e-browser/perf/scenarios.ts` +- Modify: `test/e2e-browser/perf/seed-browser-storage.ts` +- Modify/Rename: `test/unit/client/components/agent-chat/*` +- Modify: `test/unit/client/components/HistoryView.mobile.test.tsx` +- Modify: `test/unit/client/components/ContextMenuProvider.test.tsx` +- Modify/Rename: `test/unit/client/components/SettingsView.agent-chat.test.tsx` +- Modify/Rename: `test/unit/client/components/context-menu/agent-chat-actions.test.ts` +- Modify: `test/unit/client/components/context-menu/menu-defs.test.ts` +- Modify: `test/unit/client/store/crossTabSync.test.ts` +- Modify: `test/unit/client/lib/sdk-message-handler.session-lost.test.ts` +- Modify: `test/unit/client/ws-client-sdk.test.ts` +- Modify/Rename: `test/unit/server/ws-handler-sdk.test.ts` +- Modify: `test/server/ws-sidebar-snapshot-refresh.test.ts` +- Create: `test/e2e-browser/specs/fresh-agent.spec.ts` +- Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +- Modify: existing Playwright helpers only if needed +- Test: all targeted unit, integration, and e2e suites below + +Rename or consolidate the old browser specs into `fresh-agent.spec.ts` and `fresh-agent-mobile.spec.ts` unless a surviving split is clearly better, in which case keep the replacement names on a `fresh-agent-*` pattern instead of leaving `agent-chat` names behind. + +- [ ] **Step 1: Identify or write the failing tests** + +Add the browser-level proof of the requested outcome: + +```ts +test('creates and resumes freshcodex with fork lineage and worktree metadata intact', async ({ page }) => { + await expect(page.getByRole('button', { name: /freshcodex/i })).toBeVisible() + await expect(page.getByText(/worktree/i)).toBeVisible() +}) + +test('freshclaude still restores durable history and surfaces approvals and questions', async ({ page }) => { + await expect(page.getByRole('alert')).toBeVisible() +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +Expected: FAIL because the browser flows are not fully wired yet. + +- [ ] **Step 3: Write minimal implementation** + +Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep or adapt the coverage until the replacement tests prove the same behaviors in the fresh-agent world. Move `src/lib/sdk-message-handler.ts` session-lost, orphan-create, and reconnect handling into `src/lib/fresh-agent-ws.ts` and the fresh-agent thunks before deleting it. Delete obsolete client glue only after the replacement coverage and browser flows pass. + +- [ ] **Step 4: Run tests to verify targeted suites pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/title-sync-flow.test.tsx` +Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts test/e2e` +Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run the repo verification expected before handing off: + +Run: `npm run lint` +Run: `npm run test:status` +Run: `FRESHELL_TEST_SUMMARY="fresh agent platform" npm test` +Run: `npm run check` +Expected: all PASS + +If a valid check fails, continue fixing the code. Do not weaken or delete good tests. + +- [ ] **Step 6: Commit** + +```bash +git add server/mcp/freshell-tool.ts src/store/agentChatSlice.ts src/store/agentChatThunks.ts src/store/agentChatTypes.ts src/components/agent-chat src/lib/sdk-message-handler.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/sidebar-click-opens-pane.test.tsx test/e2e/title-sync-flow.test.tsx test/e2e/tool-coalesce.test.tsx test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/agent-chat-input-history.spec.ts test/e2e-browser/specs/pane-activity-indicator.spec.ts test/e2e-browser/specs/tab-management.spec.ts test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts test/e2e-browser/perf/audit-contract.ts test/e2e-browser/perf/run-sample.ts test/e2e-browser/perf/scenarios.ts test/e2e-browser/perf/seed-browser-storage.ts test/unit/client/components/agent-chat test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/SettingsView.agent-chat.test.tsx test/unit/client/components/context-menu/agent-chat-actions.test.ts test/unit/client/components/context-menu/menu-defs.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts +git commit -m "refactor: remove legacy agent chat architecture" +``` + +## OpenCode design constraint for later work + +Do not ship `freshopencode` here. Do ensure the final architecture already supports it: + +- disabled registry entry may exist now +- adapter interface already supports explicit permission and command flows plus server-driven event streams +- normalized model already has diff, review, worktree, child-thread, and artifact concepts +- no client code assumes Claude block messages or Codex review objects are universal + +## Implementation notes for the executing agent + +- Work only in `/home/user/code/freshell/.worktrees/fresh-agent-platform`. +- Reuse the existing Codex app-server runtime, client, and planner instead of creating a parallel stack. +- Preserve current Freshclaude behavior while renaming the architecture underneath it. +- Preserve `kilroy` as a hidden Claude-backed fresh-agent type. +- Preserve saved tabs and settings; do not “solve” migration by clearing local storage. +- Update remote snapshot and tab registry code in the same migration as local pane persistence. +- Reuse and promote existing shared renderer primitives before creating new ones. +- Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere. +- Use Playwright for browser and mobile e2e, not vitest-only pseudo-e2e files. +- Broad runs go through the test coordinator. Check `npm run test:status` before them. diff --git a/docs/plans/2026-04-30-freshcodex-contract-foundation.md b/docs/plans/2026-04-30-freshcodex-contract-foundation.md new file mode 100644 index 000000000..1647fdfaa --- /dev/null +++ b/docs/plans/2026-04-30-freshcodex-contract-foundation.md @@ -0,0 +1,5564 @@ +# Freshcodex Contract Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish Freshcodex as a first-class rich client on the shared fresh-agent foundation, with strict shared contracts, typed Codex normalization, interactive Codex actions, scalable transcript/diff UX, and full regression coverage. + +**Architecture:** Keep `fresh-agent` as the shared product domain, but make the normalized contract real instead of implicit: every snapshot, turn page, turn body, transcript item, action response, and provider extension crosses server/client boundaries through shared Zod schemas. Freshcodex uses the official Codex app-server as its source of truth for thread lifecycle, turn lifecycle, fork, interrupt, approvals, questions, review/diff items, token usage, worktrees, and child threads; the client consumes only typed fresh-agent data and never reaches into Claude session state. Freshclaude remains supported through the existing adapter, but this plan optimizes implementation order and tests for Freshcodex correctness. + +**Tech Stack:** TypeScript, Zod, React 18, Redux Toolkit, Express, WebSocket JSON-RPC, Codex app-server, existing read-model scheduler, react-window, Vitest, Testing Library, Playwright browser e2e. + +--- + +## Current State + +The implementation workspace is `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation`. At this planning checkpoint the branch already contains `origin/main`, but Task 1 must re-check the exact ahead/behind counts before implementation because main can move between planning and execution. If `origin/main` has moved, merge it into this worktree branch before contract work. If `origin/main` is already contained, run the merge-sensitive verification and skip the no-op merge commit. The branch already contains: + +- `kind: 'fresh-agent'` pane content with `sessionType` separate from runtime `provider`. +- Claude and Codex runtime adapters under `server/fresh-agent/adapters/*`. +- Fresh-agent REST routes and WebSocket messages. +- A shared FreshAgent shell that can render Freshclaude and Freshcodex snapshots. +- Basic Codex rich snapshot metadata for diffs, worktrees, child threads, review, fork lineage, and token totals. +- Regression coverage for the initial cutover and the last nonconvergence closure pass. + +Two existing implementation seams must be corrected before the new feature work can be considered a durable shared foundation: + +- `server/fresh-agent/provider-registry.ts` currently stores one registration per runtime provider. Because both `freshclaude` and hidden `kilroy` use `provider: 'claude'`, the last registration can overwrite the runtime-provider lookup. Split session identity registration from runtime adapter registration so many `sessionType` values can intentionally share one provider adapter without changing lookup semantics. +- `src/store/freshAgentSlice.ts`, `src/store/freshAgentTypes.ts`, and `src/store/freshAgentThunks.ts` currently re-export or alias legacy agent-chat state/thunks. `src/lib/pane-activity.ts` also reads fresh-agent pane activity from `agentChatSessions`. That was acceptable as a temporary bridge, but it is not a shared fresh-agent foundation. Fresh-agent state, thunks, activity projection, and action names must be based on the shared fresh-agent contract; legacy agent-chat may keep its own slice until Freshclaude is fully ported. +- Freshcodex defaults and restored-pane runtime settings currently flow through helper files that still assume Claude-shaped agent-chat values. `src/lib/session-type-utils.ts`, `src/store/tabsSlice.ts`, `src/lib/tab-registry-snapshot.ts`, `src/store/paneTreeValidation.ts`, `src/components/TabsView.tsx`, and pane persistence tests must be updated so Codex-shaped approval policy, sandbox, and effort values survive picker creation, session resume, browser persistence, remote tab snapshots, and hydration. + +Recent mainline fixes touched exactly the areas this project depends on: agent-chat auto-title, mobile keyboard/touch behavior, stale pane hydration, two-browser reconnect recovery, and Codex app-server startup/init hardening. Those changes are present at this planning checkpoint, but Task 1 must preserve them and repeat the main-sync gate if `origin/main` moves again before implementation so the implementation does not reintroduce known fixed bugs. + +## Local Codex Schema Audit + +These facts were verified in this worktree against the locally installed CLI, not from memory: + +```bash +codex --version +# codex-cli 0.128.0 +rm -rf /tmp/freshell-codex-schema-0.128.0 +mkdir -p /tmp/freshell-codex-schema-0.128.0/ts /tmp/freshell-codex-schema-0.128.0/json +codex app-server generate-ts --out /tmp/freshell-codex-schema-0.128.0/ts +codex app-server generate-json-schema --out /tmp/freshell-codex-schema-0.128.0/json +``` + +The generated sources that matter most are: + +- `/tmp/freshell-codex-schema-0.128.0/json/JSONRPCRequest.json`, `JSONRPCResponse.json`, `JSONRPCError.json`, `JSONRPCNotification.json`, and `JSONRPCMessage.json`. +- `/tmp/freshell-codex-schema-0.128.0/ts/RequestId.ts`, `ClientRequest.ts`, `ClientNotification.ts`, `ServerRequest.ts`, `ServerNotification.ts`, `InitializeParams.ts`, `InitializeResponse.ts`, and `InitializeCapabilities.ts`. +- `/tmp/freshell-codex-schema-0.128.0/ts/v2/ThreadStartParams.ts`, `ThreadStartResponse.ts`, `ThreadResumeParams.ts`, `ThreadReadParams.ts`, `ThreadReadResponse.ts`, `ThreadTurnsListParams.ts`, `ThreadTurnsListResponse.ts`, `ThreadForkParams.ts`, `ThreadForkResponse.ts`, `TurnStartParams.ts`, `TurnStartResponse.ts`, `TurnInterruptParams.ts`, and `TurnInterruptResponse.ts`. +- `/tmp/freshell-codex-schema-0.128.0/ts/v2/Thread.ts`, `Turn.ts`, `ThreadItem.ts`, `UserInput.ts`, `ThreadStatus.ts`, `TurnStatus.ts`, the v2 approval/request param and response files, and `DynamicToolCallResponse.ts`. +- Legacy root server-request files are still generated in this local schema and remain part of the Freshcodex unblock contract: `/tmp/freshell-codex-schema-0.128.0/ts/ApplyPatchApprovalParams.ts`, `ExecCommandApprovalParams.ts`, `ApplyPatchApprovalResponse.ts`, `ExecCommandApprovalResponse.ts`, and `ReviewDecision.ts`. +- Runtime-setting and identity leaf types are part of the contract, not incidental dependencies. The plan must also preserve and audit `ReasoningEffort.ts`, `v2/AskForApproval.ts`, `v2/SandboxMode.ts`, `v2/SandboxPolicy.ts`, `v2/NetworkAccess.ts`, `v2/UserInput.ts`, `v2/ThreadStatus.ts`, `v2/TurnStatus.ts`, `v2/ThreadActiveFlag.ts`, `v2/SessionSource.ts`, and `SubAgentSource.ts` because those files define the values Freshcodex sends to Codex and the source/subagent shapes Freshcodex projects into history and child-thread UI. + +Schema-grounded protocol facts to preserve: + +- Codex app-server supports `--listen stdio://`, `unix://`, `ws://IP:PORT`, and `off`; `stdio://` is the default. Freshcodex rich runtime should use stdio; keep the existing websocket runtime only for raw Codex terminal `--remote` attach. +- JSON-RPC envelopes omit `"jsonrpc": "2.0"`. Request ids are strings or integer numbers on the wire. Generated TypeScript exposes this as `string | number`, but generated JSON Schema constrains numeric ids to `integer`; server-initiated request ids must round-trip unchanged. +- JSON-RPC requests are `{ id, method, params?, trace? }`; responses are `{ id, result }`; errors are `{ id, error: { code, message, data? } }`; notifications are `{ method, params? }`. +- Initialization is `initialize` with `{ clientInfo, capabilities }`, followed by exactly one client notification `{ method: 'initialized' }` after a valid response. `InitializeCapabilities` has `experimentalApi` and optional `optOutNotificationMethods`. `InitializeResponse` has `userAgent`, `codexHome`, `platformFamily`, and `platformOs`; there is no `protocolVersion` field in this local schema. Because this plan checks in and classifies the normal generated schema, Freshcodex must initialize with `experimentalApi: false`. If a future plan opts into experimental APIs, it must regenerate the checked-in snapshot with `--experimental`, classify every added method/field/notification, and update fixtures before sending `experimentalApi: true`. +- Generated client methods relevant enough to classify include `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `thread/compact/start`, `thread/rollback`, `turn/start`, `turn/steer`, `turn/interrupt`, `review/start`, `model/list`, and `modelProvider/capabilities/read`; Task 4 defines which of these Freshcodex implements now versus disables with a clear unsupported path. There is no `thread/turn/read` method. +- `thread/start` accepts runtime settings such as `model`, `modelProvider`, `serviceTier`, `cwd`, `approvalPolicy`, `approvalsReviewer`, `sandbox`, `config`, instructions/personality, `ephemeral`, and `sessionStartSource`; it does not accept `richClient`, `experimentalRawEvents`, or `persistExtendedHistory`. +- `thread/resume` accepts `threadId`, the same major runtime overrides, and `excludeTurns?: boolean`; it does not accept `persistExtendedHistory`. Freshcodex restored-pane attach and any adapter path that resumes a thread before calling `thread/turns/list` must send `excludeTurns: true` so app-server does not populate the resumed `thread.turns` with a full transcript as part of normal snapshot/attach work. +- `thread/read` params are exactly `{ threadId: string, includeTurns: boolean }`. `includeTurns` is required in the generated TypeScript. The response is `{ thread }`. +- `thread/turns/list` params are `{ threadId, cursor?, limit?, sortDirection? }`. It does not accept `revision` or `includeBodies`. The response is `{ data, nextCursor, backwardsCursor }`. +- `turn/start` params are `{ threadId, input, cwd?, approvalPolicy?, approvalsReviewer?, sandboxPolicy?, model?, serviceTier?, effort?, summary?, personality?, outputSchema? }`. Input is an array of generated `UserInput`: text is `{ type: 'text', text, text_elements: [] }`, remote/data images are `{ type: 'image', url }`, local images are `{ type: 'localImage', path }`, skills are `{ type: 'skill', name, path }`, and mentions are `{ type: 'mention', name, path }`. +- Implemented client-request parameter schemas are outbound safety gates and must be strict generated-shape schemas, not permissive `.passthrough()` bags. They should reject stale Freshell-only fields such as `persistExtendedHistory`, `richClient`, `experimentalRawEvents`, `revision`, `includeBodies`, provider-only runtime values, or misspelled generated fields before a request reaches Codex app-server. `.passthrough()` is acceptable for known result/entity schemas only where forward-compatible extra app-server fields are intentionally preserved or ignored after generated-required fields are enforced. +- Codex reasoning effort values are generated as `"none" | "minimal" | "low" | "medium" | "high" | "xhigh"`. Freshcodex must not reuse Claude's legacy `"max"` effort value; if `"max"` is present in migrated settings, show a controlled unsupported Freshcodex settings error or map only through an explicit user-visible migration rule added in this plan. +- Codex approval policy values are generated as `"untrusted" | "on-failure" | "on-request" | "never" | { granular: ... }`. Freshcodex must not send Claude permission modes such as `"bypassPermissions"` as Codex `approvalPolicy`. The generated JSON Schema defaults `AskForApproval.granular.skill_approval` and `AskForApproval.granular.request_permissions` to `false` even though the generated TypeScript type prints them as required; raw Codex protocol schemas must accept those omitted fields and normalize them to `false`. +- Codex sandbox settings are split across APIs: `thread/start`, `thread/resume`, and `thread/fork` accept string `sandbox?: "read-only" | "workspace-write" | "danger-full-access"`, while `turn/start` accepts structured `sandboxPolicy`. `SandboxPolicy.externalSandbox.networkAccess` uses generated `NetworkAccess` values `"restricted" | "enabled"`, not a free-form payload. Generated JSON Schema defaults omitted sandbox-policy fields (`readOnly.networkAccess`, `externalSandbox.networkAccess`, and `workspaceWrite`'s `writableRoots`, `networkAccess`, `excludeTmpdirEnvVar`, and `excludeSlashTmp`); raw protocol schemas must accept those omitted fields and normalize them before lifecycle responses cross into fresh-agent contracts. Do not send the thread-level `sandbox` string to `turn/start`. +- `turn/start` returns `{ turn }`. `turn/interrupt` requires `{ threadId, turnId }` and returns `{}`. +- `thread/fork` accepts `threadId`, runtime overrides, `ephemeral?`, and `excludeTurns?`; generated TypeScript returns `{ thread, model, modelProvider, serviceTier, cwd, instructionSources, approvalPolicy, approvalsReviewer, sandbox, reasoningEffort }`, while generated JSON Schema requires only `thread`, `model`, `modelProvider`, `cwd`, `approvalPolicy`, `approvalsReviewer`, and `sandbox`. Raw protocol schemas must accept omitted `serviceTier`, `instructionSources`, and `reasoningEffort` and normalize them to `null`, `[]`, and `null`. +- `review/start` accepts `{ threadId, target, delivery? }` where target is `uncommittedChanges`, `baseBranch`, `commit`, or `custom`, and delivery is `inline` or `detached`. It returns `{ turn, reviewThreadId }`; the review thread id must be preserved in fresh-agent action results and extensions so inline and future detached review flows can be tracked correctly. +- `thread/loaded/list` returns `{ data: string[], nextCursor? }`, not thread summaries. Raw protocol schemas must normalize omitted `nextCursor` to `null`. Any fresh-agent loaded-thread UI or API must expose loaded ids directly or hydrate them through `thread/read`/`thread/list`; it must not pretend this app-server method returns rich session rows. +- `thread/list` is paginated. Params include `cursor`, `limit`, `sortKey`, `sortDirection`, `modelProviders`, `sourceKinds`, `archived`, `cwd`, `useStateDbOnly`, and `searchTerm`; the TypeScript response is `{ data: Thread[], nextCursor, backwardsCursor }`, while generated JSON Schema requires only `data` and marks cursor fields optional/null. Raw protocol schemas must accept missing cursors and normalize them to `null` before producing fresh-agent page contracts. Freshcodex history/session APIs must preserve both normalized cursors instead of collapsing the response to an array. +- `model/list` is paginated. Params are `{ cursor?, limit?, includeHidden? }`; the TypeScript response is `{ data: Model[], nextCursor }`, while generated JSON Schema requires only `data` and marks `nextCursor` optional/null. Raw protocol schemas must accept missing `nextCursor` and normalize it to `null` before producing fresh-agent page contracts. A convenience settings helper may accumulate pages for a dropdown, but the adapter/runtime/router/API contract must remain page-shaped so hidden or future large model lists are not silently truncated. +- Generated `ThreadSourceKind` values are `cli`, `vscode`, `exec`, `appServer`, `subAgent`, `subAgentReview`, `subAgentCompact`, `subAgentThreadSpawn`, `subAgentOther`, and `unknown`. Freshcodex rich history must explicitly request generated user-visible/resumable Codex sources (`cli`, `vscode`, `exec`, and `appServer`) plus every generated `subAgent*` kind, including `subAgentCompact`, so CLI-created threads, app-server-created threads, command/exec sessions, and child-agent sessions are not hidden by the explicit source filter. The `vscode` source is required because local runtime probes against `codex app-server --listen stdio://` on `codex-cli 0.128.0` returned newly created app-server threads with `source: "vscode"` even when the client was Freshell and `serviceName: "freshell"` was supplied. `unknown` must parse and preserve when returned, but it should not be in the default history filter unless the UI exposes an explicit "unknown source" option. +- Generated `ThreadStartSource` values are only `"startup"` and `"clear"`. Do not use `sessionStartSource` as a Freshell/app-server source marker or send `"appServer"` there. +- `Thread` has `id`, optional/null `forkedFromId`, `preview`, `ephemeral`, `modelProvider`, Unix-second timestamps, structured `status`, optional/null `path`, `cwd`, `cliVersion`, `source`, optional/null subagent metadata, optional/null `gitInfo`, optional/null `name`, and `turns`. `Turn` has `id`, `items`, `status`, optional/null `error`, optional/null Unix-second `startedAt`/`completedAt` values, and optional/null `durationMs`. Fresh-agent contract timestamps may stay ISO strings for UI consistency, but Codex raw protocol schemas and fixtures must parse numeric app-server timestamps and normalize omitted nullable fields explicitly. +- Generated JSON Schema requires the core `Thread` metadata envelope even when turn bodies are omitted. At minimum, schema-valid wire fixtures must include `id`, `preview`, `ephemeral`, `modelProvider`, `createdAt`, `updatedAt`, structured `status`, `cwd`, `cliVersion`, `source`, and `turns`. Optional/null fields such as `forkedFromId`, `path`, `agentNickname`, `agentRole`, `gitInfo`, and `name` may be omitted on the JSON wire and must normalize to `null` before provider extensions or fresh-agent contracts depend on them. `turns` is a required array that may be empty; do not mark it optional in `CodexThreadSchema` just because `thread/read { includeTurns: false }` returns an empty list. +- `ThreadStatus` is structured: `{ type: 'notLoaded' } | { type: 'idle' } | { type: 'systemError' } | { type: 'active', activeFlags: [...] }`; `activeFlags` is required on the active variant. `TurnStatus` is `"completed" | "interrupted" | "failed" | "inProgress"`. Generated TypeScript exposes `Turn.error`, `Turn.startedAt`, `Turn.completedAt`, and `Turn.durationMs` as nullable properties, but generated JSON Schema does not require them; raw protocol schemas must accept omitted values and normalize them to `null`. +- `Thread.source` uses generated `SessionSource`, not `ThreadSourceKind`. `ThreadSourceKind` is only the filter type for `thread/list`. `SessionSource` values include flat sources such as `"cli"`, `"vscode"`, `"exec"`, and `"appServer"`, but subagent source metadata is represented as `{ subAgent: ... }` with generated `SubAgentSource` variants such as `"review"`, `"compact"`, `{ thread_spawn: ... }`, `"memory_consolidation"`, and `{ other: string }`. Freshcodex protocol schemas, fixtures, history projection, and child-thread metadata must parse and preserve the generated `SessionSource` shape instead of flattening thread metadata to `subAgentReview`/`subAgentCompact` strings. +- Generated `ThreadItem` variants are exactly `userMessage`, `hookPrompt`, `agentMessage`, `plan`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`, `enteredReviewMode`, `exitedReviewMode`, and `contextCompaction`. +- `reasoning` items contain both `summary: string[]` and `content: string[]`. Fresh-agent reasoning items must preserve both arrays; a derived joined `text` may be added for convenience, but it must not replace the generated content list. +- Generated item status leaf types use `"inProgress"` for active work, not `"running"`: `CommandExecutionStatus` and `PatchApplyStatus` are `"inProgress" | "completed" | "failed" | "declined"`, while `McpToolCallStatus`, `DynamicToolCallStatus`, and `CollabAgentToolCallStatus` are `"inProgress" | "completed" | "failed"`. Fresh-agent transcript contracts may keep the UI-facing `"running"` value, but Codex normalization must explicitly map every generated item-level `"inProgress"` to `"running"` and must test that mapping for command, file-change, MCP tool, dynamic tool, and collaboration items. +- Generated file-change kinds are `{ type: "add" }`, `{ type: "delete" }`, and `{ type: "update", move_path: string | null }`. Fresh-agent file-change items must map `update` with `move_path: null` to `modify`, map `update` with a non-null `move_path` to `rename`, and preserve the generated `move_path` as `movePath` so diff/review UI can show moved or renamed files accurately. +- `collabAgentToolCall` carries `senderThreadId`, `receiverThreadIds: string[]`, optional `model`, optional `reasoningEffort`, and `agentsStates` keyed by child thread id. Fresh-agent collaboration items must preserve the receiver id array and agent-state metadata under typed fields; do not collapse this to a singular `receiverThreadId` / `newThreadId`, because spawned or resumed child-agent calls can involve multiple receiver threads and the shared shell needs that metadata for child-thread UX. +- `imageGeneration.status` is generated as an unconstrained string, not a fixed enum. Fresh-agent image-generation items must preserve the raw generated status string and may derive a separate UI bucket if useful; do not narrow the contract to only `pending` / `running` / `completed` / `failed`. +- Several generated `ThreadItem` variants carry structured details beyond their display label: text `UserInput` parts include `text_elements`, `hookPrompt.fragments[]` include `hookRunId`, `agentMessage` includes `phase` and `memoryCitation`, `commandExecution` includes `source`, `processId`, and `commandActions`, MCP tool calls include `server`, `mcpAppResourceUri`, structured result metadata, and duration, `dynamicToolCall` includes `namespace`, `contentItems`, `success`, and duration, `webSearch` includes structured `action`, and `imageGeneration` includes raw `result` plus optional `savedPath`. Fresh-agent contracts and fixtures must preserve these generated fields under typed item fields rather than flattening them to display-only strings. +- Generated MCP and dynamic tool-call `arguments` fields are `JsonValue`, not `Record<string, JsonValue>`. Fresh-agent `tool.input` and `dynamic_tool.input` must preserve arbitrary JSON values, including arrays, strings, numbers, booleans, and null; object-shaped tool calls are common but not a wire-contract guarantee. +- Generated `ServerRequest` variants are exactly `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/permissions/requestApproval`, `item/tool/call`, `account/chatgptAuthTokens/refresh`, `applyPatchApproval`, and `execCommandApproval`. The v2 request variants use `params.threadId` for routing, while the legacy root `applyPatchApproval` and `execCommandApproval` variants use `params.conversationId`; both fields identify the Codex thread and must route to the matching Freshcodex locator. +- Command approval responses use `{ decision: "accept" | "acceptForSession" | "decline" | "cancel" | amendment-object }`; file-change approval responses use `{ decision: "accept" | "acceptForSession" | "decline" | "cancel" }`; permission responses use `{ permissions, scope, strictAutoReview? }`; user-input responses use `{ answers }`; MCP elicitation responses use `{ action, content, _meta }`; dynamic-tool responses use `{ contentItems, success }`. Legacy `applyPatchApproval` and `execCommandApproval` responses use root `ReviewDecision` values through `{ decision }`, including `"approved"`, `"approved_for_session"`, `"denied"`, `"timed_out"`, `"abort"`, and the generated amendment-object variants; do not answer those legacy requests with v2 `"accept"` / `"decline"` decisions. +- Generated JSON Schema, not generated TypeScript formatting, is the wire-requiredness source for those legacy root requests. `ApplyPatchApprovalParams` requires `conversationId`, `callId`, and `fileChanges`; optional `reason` and `grantRoot` must normalize to `null` when omitted. `ExecCommandApprovalParams` requires `conversationId`, `callId`, `command`, `cwd`, and `parsedCmd`; optional `approvalId` and `reason` must normalize to `null` when omitted. +- `account/chatgptAuthTokens/refresh` expects real token fields in a successful result and its generated params do not include a thread locator (`threadId` or `conversationId`). Freshcodex must not fabricate an unsupported success payload for it. If Freshell cannot satisfy this request, respond with a JSON-RPC error envelope on the original server request id and surface a clear unsupported-auth-refresh runtime error to every subscribed Freshcodex pane for that rich runtime instance, since the request is not thread-addressable. +- Generated `ServerNotification` method names are slash-delimited and must be copied exactly from `ServerNotification.ts`; examples include `thread/status/changed`, `thread/tokenUsage/updated`, `turn/diff/updated`, `turn/plan/updated`, `thread/compacted`, `item/agentMessage/delta`, `item/fileChange/patchUpdated`, `serverRequest/resolved`, `thread/realtime/error`, and `thread/realtime/closed`. +- Any per-turn body API in Freshell must be an internal facade over `thread/turns/list` results or a server-side page/body cache until Codex exposes a direct turn-read request. Do not implement normal Freshcodex body hydration by repeatedly calling `thread/read { includeTurns: true }` over the full thread. +- Freshcodex must not opt out of generated notification methods that affect visible state. In particular, do not include `thread/started`, turn lifecycle, item lifecycle, token usage, diff/review, status, compaction, or error notifications in `InitializeCapabilities.optOutNotificationMethods`; suppressing those events would make the live read model stale by construction. + +Generated method inventory the executor must keep aligned with the local schema: + +- Freshcodex client-request methods to implement or intentionally leave unsupported must be generated from `ClientRequest.ts` during Task 4, not copied by hand. The implementation-required Freshcodex subset is `initialize`, `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `review/start`, `model/list`, and `modelProvider/capabilities/read`. The explicit unsupported/disabled subset for this plan is every other generated client method, including `thread/archive`, `thread/unsubscribe`, `thread/name/set`, `thread/metadata/update`, `thread/unarchive`, `thread/compact/start`, `thread/shellCommand`, `thread/approveGuardianDeniedAction`, `thread/rollback`, `thread/inject_items`, skills/plugin/marketplace/app/fs/config/account/device/feedback/fuzzy-file-search methods, standalone `command/exec*`, `turn/steer`, MCP direct-call methods, and Windows sandbox setup. Unsupported methods must have clear server/client capability labels if exposed by UI; do not silently proxy arbitrary generated methods through Freshcodex. +- Server-request methods requiring pending UI state or explicit unblock responses: `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/permissions/requestApproval`, `item/tool/call`, `account/chatgptAuthTokens/refresh`, `applyPatchApproval`, and `execCommandApproval`. +- Visible thread-located notification methods that must invalidate or patch the matching Freshcodex read model: `error`, `thread/started`, `thread/status/changed`, `thread/archived`, `thread/unarchived`, `thread/closed`, `thread/name/updated`, `thread/goal/updated`, `thread/goal/cleared`, `thread/tokenUsage/updated`, `turn/started`, `hook/started`, `turn/completed`, `hook/completed`, `turn/diff/updated`, `turn/plan/updated`, `item/started`, `item/autoApprovalReview/started`, `item/autoApprovalReview/completed`, `item/completed`, `rawResponseItem/completed`, `item/agentMessage/delta`, `item/plan/delta`, `item/commandExecution/outputDelta`, `item/commandExecution/terminalInteraction`, `item/fileChange/outputDelta`, `item/fileChange/patchUpdated`, `serverRequest/resolved`, `item/mcpToolCall/progress`, `item/reasoning/summaryTextDelta`, `item/reasoning/summaryPartAdded`, `item/reasoning/textDelta`, `thread/compacted`, `model/rerouted`, `model/verification`, `guardianWarning`, `thread/realtime/started`, `thread/realtime/itemAdded`, `thread/realtime/transcript/delta`, `thread/realtime/transcript/done`, `thread/realtime/outputAudio/delta`, `thread/realtime/sdp`, `thread/realtime/error`, and `thread/realtime/closed`. Most of these use `params.threadId`, but `thread/started` uses `params.thread.id`, and `warning` is thread-located only when `params.threadId` is non-null. Notification routing must be generated-method-specific; never fall back to the subscribed session id when a notification has no thread locator. +- Runtime-global or connection-scoped notification methods must be surfaced as runtime/capability warnings or explicitly ignored by classification, not used to invalidate an arbitrary Freshcodex thread: `command/exec/outputDelta` is scoped to a `processId` from unsupported standalone `command/exec`; `fs/changed` is scoped to a `watchId` from unsupported `fs/watch`; `mcpServer/oauthLogin/completed`, `mcpServer/startupStatus/updated`, `configWarning`, `warning` with `threadId: null`, `windows/worldWritableWarning`, and `windowsSandbox/setupCompleted` have no Freshcodex thread locator. If a future task implements the corresponding app-server feature, it must add an ownership map from that feature's request id/watch id/process id to a Freshcodex locator before treating these as thread-visible. +- Generated notifications that may be ignored only by an explicit non-visible allowlist: `skills/changed`, `account/updated`, `account/rateLimits/updated`, `app/list/updated`, `remoteControl/status/changed`, `externalAgentConfig/import/completed`, `deprecationNotice`, `fuzzyFileSearch/sessionUpdated`, `fuzzyFileSearch/sessionCompleted`, and `account/login/completed`. + +## Executable Schema Traceability Foundation + +The repeated planning-review nonconvergence was not a disagreement about product direction; it was evidence that the plan was relying on manual rediscovery of generated Codex protocol details. This plan therefore treats the generated schema snapshot and an executable traceability matrix as the foundation. The examples in this document are regression seeds, not the source of truth. If an example and the checked-in generated schema disagree, the generated schema and traceability tests win. + +Task 4 must create `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` next to the generated schema inventory. That artifact is the required ownership map for every generated Codex surface Freshcodex can encounter: + +- Client requests and responses: generated method, generated params schema, generated result schema, strict outbound parser, app-server client method, rich-runtime method, adapter/runtime-manager method, REST or WebSocket exposure, UI capability owner, fixture tests, and explicit supported or unsupported classification. +- Server notifications: generated method, generated params schema, route classification (`thread`, `runtimeGlobal`, `connectionScoped`, or `nonVisible`), exact locator extraction rule, invalidation or patch behavior, user-visible warning behavior when applicable, and tests proving no notification falls through to subscriber state as a fake thread locator. +- Server requests: generated method, generated params schema, generated response schema, request id type preservation, route classification, pending request contract, UI response owner, JSON-RPC result or error serializer, and tests proving legacy root approvals route by `conversationId` while auth refresh remains runtime-global. +- Thread, turn, item, runtime-setting, model, source, status, and leaf types: generated file, wire-required fields, generated-optional/defaulted fields, protocol parser behavior, normalized fresh-agent schema, lossless extension fields, UI renderer or supported-negative behavior, fixtures, and intentional omissions. +- Shared fresh-agent contracts: shared schema name, producer boundary, server parser, client parser, Redux owner, persistence or pane-lifecycle owner if applicable, UI consumer, fixture, and regression test. + +The traceability test is a gate, not documentation. It must fail if any generated method, notification, server request, item variant, runtime leaf enum, or shared fresh-agent schema lacks a classification. It must also fail if a classification says `implemented` but has no parser, normalizer, user-visible behavior or explicit API owner, and at least one test path. Intentional omissions are allowed only when they carry a stable reason, a typed unsupported/error behavior, and a test proving the unsupported path is clear to users or harmlessly non-visible. + +This changes how implementation tasks should be executed: + +- Do not add one-off Codex protocol facts directly to component or adapter tests first. Add or update the generated snapshot and traceability entry, then write the failing parser/normalizer/UI test derived from that entry. +- Do not classify a method as implemented merely because `CodexAppServerClient` can send it. A generated method is implemented only when it is represented through the fresh-agent adapter/runtime/API boundary and has a user-visible consumer or an intentionally documented internal owner. +- Do not add permissive catch-all transcript items, server-request responses, or notification routing fallbacks. Unknown future generated variants should fail the traceability/schema audit until intentionally modeled. +- Do not repair schema drift by weakening tests. Regenerate the reduced schema snapshot, update traceability, update parsers/normalizers/UI/tests, and then re-run `npm run audit:codex-app-server-schema`. + +## User-Visible End State + +- The Freshcodex pane picker entry creates a `fresh-agent` pane with `sessionType: 'freshcodex'` and `provider: 'codex'`. +- Creating, resuming, and refreshing Freshcodex uses Codex app-server thread APIs only over a dedicated stdio app-server runtime. No terminal scraping, no Freshcodex websocket production dependency, and no Claude state path. Existing raw Codex terminal panes keep their loopback websocket app-server launch path because terminal `--remote` attach currently requires a websocket URL. +- Freshcodex honors Codex runtime settings at create and turn time, including model, sandbox, permission/approval policy, and effort where supported by the generated local app-server schema. +- Freshcodex model and provider capability choices come from Codex app-server `model/list` and `modelProvider/capabilities/read` when the app-server is available. Model-list REST/API contracts preserve the generated `nextCursor` and the provider-level capability payload; dropdown helpers may cache or aggregate pages, but they must not hard-code model or capability assumptions into the shared shell or smuggle provider capability booleans through unknown model-item fields that shared Zod parsing will strip. +- Freshcodex can send text and image inputs, interrupt an active turn, fork a thread into a new freshcodex pane, answer Codex command/file/permission approval requests, answer request-user-input prompts, answer MCP elicitations, and reject unsupported dynamic tool calls with a clear response that unblocks the turn. +- Freshcodex can start a Codex review through `review/start` for uncommitted changes by default, preserve the schema `target`, `delivery`, and returned `reviewThreadId`, and then render review status/output through the shared workspace panel. +- Freshcodex receives Codex app-server notifications live. Turn started/completed, item started/completed, token usage, status, diff, review, compaction, child-agent/collaboration, and thread metadata notifications invalidate or patch the normalized read model and reach subscribed browsers as `freshAgent.event` without requiring a manual refresh. +- Unsupported Codex capabilities are disabled with clear labels. Do not silently fall back to raw terminal mode. +- The Freshcodex transcript renders normalized item cards for user messages, hook prompts, agent messages, plans, reasoning, command executions, file changes/diffs, MCP tool calls, collaboration calls, web searches, image views, image generations, review mode, context compaction, dynamic tool calls, errors, and tool/request prompts. Codex user-message content is preserved as multi-part message content, including mixed text and images; do not collapse a multi-part Codex `userMessage.content` array into a single text-only item. +- Long transcripts page through `thread/turns/list`, hydrate from page-provided turn bodies or a bounded body cache, and render through virtualization so mobile remains responsive. Freshcodex snapshots must not load every turn body as the normal path. +- Diff/review/worktree/fork metadata is usable, not just listed. Users can inspect file-change diffs, see review status/output, see fork lineage, see child threads, and identify worktree branch/path. +- Freshcodex has typed load/create/action errors that point to the failing boundary: app-server unavailable, app-server protocol invalid, fresh-agent contract invalid, stale revision, unsupported capability, unauthorized session, or lost session. +- Freshclaude still works after the refactor. Hidden `kilroy` still resolves as Claude-backed. `freshopencode` remains disabled and unimplemented. +- Existing Freshclaude saved layouts, settings, remote tab snapshots, and history stay readable. Existing Freshcodex saved panes must be able to attach after a browser reload or server restart by resuming/loading the Codex app-server thread before snapshot/action work. Do not clear browser storage to force migration. + +## Contracts And Invariants + +- Durable read-model contracts live in `shared/fresh-agent-contract.ts`; pane lifecycle state stays in `src/store/paneTypes.ts` and must not leak into durable snapshot schemas. +- `provider` means runtime family: `claude`, `codex`, or later `opencode`. +- `sessionType` means user-facing identity: `freshclaude`, `freshcodex`, `kilroy`, or disabled `freshopencode`. +- Every fresh-agent read-model contract and browser/server API that identifies a session must include `sessionType` as well as `provider` and `threadId`. Do not infer user-facing identity from `provider`; multiple session types can share one runtime provider. Use one canonical REST locator shape for fresh-agent thread resources: `/api/fresh-agent/threads/:sessionType/:provider/:threadId` and `/api/fresh-agent/threads/:sessionType/:provider/:threadId/turns...`. Do not keep the old provider-only route as the primary API because it makes `freshclaude`/`kilroy` and future shared-provider clients ambiguous. +- Fresh-agent live session tracking and action routing must key sessions by the full locator `{ sessionType, provider, threadId }`, not by `sessionId` alone. Claude, Codex, and later OpenCode can all expose opaque ids, and a durable foundation must not depend on cross-provider id uniqueness. WebSocket action messages may keep `sessionId` as the user-facing field name for compatibility, but they must also carry `sessionType` and `provider`, and the runtime manager must validate the full locator before dispatching an action. +- Fresh-agent client state, thunk caches, pane activity projection, and subscription bookkeeping must use the same canonical full-locator key as the server runtime manager. Do not index `freshAgent.sessions` by bare `sessionId`; a server-side routing fix is incomplete if Redux or pane activity can still collapse two providers that reuse an opaque id. +- `server/fresh-agent/provider-registry.ts` must model two separate concepts: a session-type descriptor registry and a runtime-provider adapter registry. Runtime adapter lookup by provider must not be overwritten by another session type using the same provider. +- `src/store/freshAgentSlice.ts` must become an actual fresh-agent slice with fresh-agent action names and contract-shaped state. It must not re-export `agentChatSlice`; `src/store/freshAgentTypes.ts` must not alias `agentChatTypes`; and `src/store/freshAgentThunks.ts` must not alias `agentChatThunks`. +- All fresh-agent server adapter outputs parse before leaving `server/fresh-agent/runtime-manager.ts`. +- All fresh-agent REST payloads parse again in `src/lib/api.ts` before UI state sees them. +- A snapshot, turn page, or turn body with an invalid contract is a controlled error, not partially rendered data. +- Freshcodex snapshots are lightweight. They may include thread metadata, pending request state, extensions, and at most a bounded initial turn page. They must not call `thread/read { includeTurns: true }` merely to render the normal snapshot. +- Freshcodex restored-session loading is also page-first. When a browser-restored or server-restarted Freshcodex pane needs to load a thread into the stdio app-server process, the Codex adapter must call `thread/resume` with `excludeTurns: true` and then fetch the visible page through `thread/turns/list`; it must not rely on a default `thread/resume` response that may include all turns. +- Fresh-agent `revision` is a Freshell normalized read-model revision, not a Codex app-server revision. For Codex, derive it from runtime-manager event ordering and stable thread metadata such as `thread.updatedAt`; preserve the app-server source version separately in `extensions.codex.sourceVersion`. Turn page and turn body requests compare against the Freshell normalized revision. Do not send nonexistent Codex `revision` fields to app-server requests. +- Codex app-server protocol schemas are owned by `server/coding-cli/codex-app-server/protocol.ts`, and must be cross-checked with `codex app-server generate-json-schema` during implementation. +- Codex generated-schema traceability is owned by `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts`, and shared fresh-agent contract traceability is owned by `test/fixtures/fresh-agent/contract-traceability.ts`. These are executable completeness gates, not optional docs. Any generated surface or shared contract added without a parser, normalizer, API/action owner, UI behavior or supported-negative owner, and test path must fail the relevant traceability test before implementation can proceed. +- Codex app-server transports are separated by runtime purpose. `server/coding-cli/codex-app-server/client.ts` owns JSON-RPC request/response semantics over an injected transport; `transport.ts` owns concrete stdio JSONL and websocket framing. `runtime.ts` remains the loopback websocket runtime used by `CodexLaunchPlanner` and raw Codex terminal `--remote` attach. New `rich-runtime.ts` is the Freshcodex-only stdio runtime and must not return or require a `wsUrl`. +- Codex JSON-RPC messages omit the `jsonrpc` property on the wire and emit `initialized` exactly once after successful `initialize`. +- Codex request ids must round-trip as generated `string | number`; never coerce server-initiated request ids to numbers before responding. Runtime Zod schemas should use `z.number().int()` for the numeric branch because the generated JSON wire schema constrains numeric `RequestId` values to integers even though TypeScript can only represent that branch as `number`. +- Fresh-agent pending approval/question/request contracts that represent Codex server-initiated JSON-RPC requests must also carry generated `string | number` request ids. Browser-created request ids, such as `freshAgent.create.requestId`, may remain strings, but Codex server request ids must not be narrowed to `NonEmptyString` in shared response schemas, Redux pending state, or WebSocket response actions. +- Provider-specific detail is preserved under typed extension schemas, not ad-hoc `Record<string, unknown>` blobs in transcript items. +- A normalized turn is a lifecycle/container boundary, not a single message role. Codex `Turn` objects contain mixed user, assistant, tool, and system items, so role belongs on message transcript items and turn-level `role` must be optional/legacy-only. Do not invent a turn role to satisfy the contract. +- A Codex app-server item may normalize to zero, one, or many fresh-agent transcript items. In particular, `userMessage.content` can contain multiple text/image/localImage parts. Codex item normalization must return an array and turn normalization must `flatMap` item output while preserving stable derived ids for split content parts. +- Codex item-status normalization is a separate leaf mapping from thread/turn status normalization. Do not pass raw item status strings through to fresh-agent transcript item contracts: map generated `"inProgress"` to the shared UI `"running"` status and preserve generated terminal states (`"completed"`, `"failed"`, `"declined"`) where the shared item kind supports them. +- Codex reasoning normalization must preserve both generated `summary` and `content` arrays. Do not collapse `content` into a lossy preview-only field. +- Codex file-change normalization must preserve generated rename/move metadata. Do not flatten all `{ type: "update" }` changes to `modify`; a non-null `move_path` is a first-class diff/review detail that belongs in the shared contract. +- Codex collaboration items must preserve all generated receiver thread ids and agent states. The normalized item can add convenience display labels, but the durable contract must keep `receiverThreadIds` as an array and must not replace it with a single `receiverThreadId`. +- Codex image-generation normalization must preserve the generated raw `status: string`. If the UI wants a small visual state enum, derive it into a separate optional display field instead of rejecting unknown raw statuses at the contract boundary. +- Codex generated item-detail normalization must be lossless for the fields the local schema exposes today. Display-oriented convenience fields are fine, but the shared contract must keep structured text elements, hook run ids, agent-message phase/memory citations, command source/action metadata, MCP resource/result metadata, dynamic-tool output content, web-search actions, and image-generation `result`/`savedPath` so later UX work does not need another contract migration. +- Codex tool-call argument normalization must preserve generated `JsonValue` exactly. Do not type fresh-agent tool inputs as object records or coerce non-object arguments into strings for display. +- Codex `UserInput` content parts include text, image, localImage, skill, and mention. Freshcodex message content and renderers must preserve every generated part type; do not silently drop skill or mention references from existing threads. +- Freshcodex runtime settings use Codex-shaped values at the app-server boundary. Shared UI/state may keep the historical field name `permissionMode`, but the value sent to Codex must parse as generated `AskForApproval`; `effort` must parse as generated `ReasoningEffort`; and turn-time sandbox overrides must be converted to generated `SandboxPolicy` with a clear error if the selected mode cannot be represented. +- `FreshAgentRuntimeSettingsSchema` may remain a broad persisted/UI shape only because Freshclaude and Freshcodex share historical field names. Any executable action parser (`freshAgent.create`, `freshAgent.send`, `freshAgent.attach`, review/fork settings, and REST action bodies) must resolve the provider first, then validate settings with a provider-specific schema before the runtime manager or adapter receives the action. A Freshcodex action carrying Claude-only values such as `permissionMode: "bypassPermissions"` or `effort: "max"` must fail in WebSocket/controller parsing with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING`, not merely inside the Codex adapter after generic parsing has already accepted it. +- Codex turn bodies are page-first. `thread/turns/list` returns `Turn` objects with items, so Freshcodex should normalize those page results directly into turn bodies. A server-side LRU turn-body cache may serve `/turns/:turnId` for bodies already loaded from pages; the adapter must not implement body hydration by repeatedly calling `thread/read { includeTurns: true }` over the full thread. +- Every app-server item/request type documented by the current local generated schema must either have a normalized UI representation or a clear supported-negative response path. Unknown future item types should fail contract validation until intentionally modeled. Do not add a catch-all transcript fallback without explicit approval. +- Every Codex normalization fixture that claims to model an app-server `Thread`, `Turn`, `ThreadItem`, `ServerRequest`, or `ServerNotification` must first parse through the local generated Codex protocol schemas in `server/coding-cli/codex-app-server/protocol.ts`. Do not write tests against impossible mock shapes. If an example in this plan differs from the generated schema, the generated schema wins and the fixture must be corrected. +- Codex protocol schemas in `server/coding-cli/codex-app-server/protocol.ts` must reject missing generated JSON-Schema-required fields, while accepting generated-optional wire fields and normalizing them at the protocol boundary. Do not use permissive partial schemas for app-server entities that generated JSON Schema makes required. Known important required examples are `Thread.turns`, `Thread.cwd`, `Thread.source`, `Thread.createdAt`, `Thread.updatedAt`, `Turn.items`, and `Turn.status`. Known important optional/default examples that must parse and normalize are `Thread.forkedFromId`, `Thread.path`, `Thread.agentNickname`, `Thread.agentRole`, `Thread.gitInfo`, `Thread.name`, `Turn.error`, `Turn.startedAt`, `Turn.completedAt`, `Turn.durationMs`, `ThreadListResponse.nextCursor`, `ThreadListResponse.backwardsCursor`, `ThreadTurnsListResponse.nextCursor`, `ThreadTurnsListResponse.backwardsCursor`, `ThreadLoadedListResponse.nextCursor`, and `ModelListResponse.nextCursor`. +- Every app-server notification method documented by the current local generated schema that can affect visible Freshcodex state must be intentionally handled and routed by its generated locator shape. At minimum, turn lifecycle, item lifecycle, token usage, status, diff/review, thread metadata/name/archive/close, context compaction, collaboration/child-agent, realtime error/close, and app-server error notifications must trigger a fresh-agent invalidation event for the generated target thread or a typed runtime/global warning when no thread locator exists. Unknown future notification methods should be logged at debug level and ignored only if they are explicitly classified as non-visible; visible-state notifications must not be silently dropped. Do not route no-locator notifications to the current subscriber's `sessionId`; that makes global app-server events corrupt unrelated thread state. +- Server-initiated Codex requests that include a generated thread locator are routed to that Freshcodex thread. For v2 request params the locator is `threadId`; for legacy root `applyPatchApproval` and `execCommandApproval` params the locator is `conversationId`. Server-initiated Codex requests without either locator, currently `account/chatgptAuthTokens/refresh`, are runtime-global; they must be answered on the original JSON-RPC id and broadcast as a typed runtime error to subscribed Freshcodex panes instead of being dropped or attached to an arbitrary thread. +- Codex server-request response shapes must stay discriminated by generated request method all the way through the shared contract, WebSocket protocol, controller, and adapter. Do not collapse all prompts to Claude-style `answers: Record<string, string>` or `decision: string`: `item/tool/requestUserInput` responds with `{ answers: Record<string, { answers: string[] }> }`, `mcpServer/elicitation/request` responds with `{ action, content, _meta }`, `item/permissions/requestApproval` responds with `{ permissions, scope, strictAutoReview? }`, and command/file approval responses keep their generated decision payloads. +- Async pane updates in `FreshAgentView` must use targeted `mergePaneContent` updates unless replacing an entire pane is intentional. +- Freshcodex tests must be able to render without `state.agentChat.sessions` or Claude restore helpers. +- Main-branch fixes for auto-title, mobile keyboard/touch target behavior, stale pane hydration, reconnect recovery, and app-server stdio/init hardening must survive the cutover. + +## File Structure + +### Create + +- `shared/fresh-agent-contract.ts` - Zod schemas and exported types for snapshots, turn pages, turn bodies, items, provider extensions, action responses, and contract errors. +- `src/lib/fresh-agent-api-error.ts` - typed client error helper for contract parse failures and fresh-agent API errors. +- `server/coding-cli/codex-app-server/transport.ts` - app-server transport abstraction plus stdio JSONL and websocket implementations that own framing, close/error handling, and request/notification delivery. +- `server/coding-cli/codex-app-server/rich-runtime.ts` - Freshcodex-only stdio app-server runtime that exposes rich thread/turn/fork/request APIs without a terminal `wsUrl`. +- `src/components/fresh-agent/useFreshAgentThreadController.ts` - controller hook for create/attach/snapshot/action/pagination state. +- `src/components/fresh-agent/FreshAgentShell.tsx` - pure presentational shell for header, banners, transcript, composer, sidebar, and workspace panel. +- `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx` - virtualized transcript list backed by turn summaries and hydrated bodies. +- `src/components/fresh-agent/FreshAgentWorkspacePanel.tsx` - typed worktree, child-thread, review, fork, and diff browser. +- `src/components/fresh-agent/FreshAgentItemCard.tsx` - normalized transcript item rendering. +- `src/components/fresh-agent/fresh-agent-policy.ts` - small runtime/session policy helpers for labels, action availability, and restore behavior. +- `test/fixtures/fresh-agent/codex/contract-fixtures.ts` - schema-validated Codex snapshot, turn page, turn body, event, approval, review, and fork fixtures. +- `test/fixtures/fresh-agent/claude/contract-fixtures.ts` - schema-validated Claude snapshot/page/body fixtures that preserve existing behavior. +- `test/fixtures/fresh-agent/contract-traceability.ts` - executable owner map from shared fresh-agent schemas to producer boundaries, parser boundaries, state owners, UI consumers, fixtures, and tests. +- `test/unit/shared/fresh-agent-contract.test.ts` +- `test/unit/shared/fresh-agent-contract-traceability.test.ts` +- `test/unit/client/lib/api.fresh-agent-contract.test.ts` +- `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx` +- `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx` +- `test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx` +- `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx` +- `test/unit/server/fresh-agent/contract-boundary.test.ts` +- `test/unit/server/coding-cli/codex-app-server/transport.test.ts` - stdio JSONL and websocket transport framing, request/notification delivery, and close/error behavior. +- `test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts` - Freshcodex stdio runtime lifecycle and rich API proxy coverage. +- `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/` - checked-in schema audit snapshot generated from local `codex app-server generate-ts` / `generate-json-schema`, reduced to the files needed by tests. +- `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts` - helper that extracts method/type inventories from the checked-in generated schema snapshot so protocol tests do not depend on `/tmp` state. +- `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` - executable traceability matrix classifying every generated client request, server request, server notification, item variant, runtime leaf type, response shape, and intentional omission. +- `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts` +- `scripts/audit-codex-app-server-schema.ts` - developer audit script that regenerates the local Codex schema, compares it with the checked-in fixture inventory, and prints the exact methods/types requiring reclassification. + +### Modify + +- `shared/fresh-agent.ts` +- `shared/read-models.ts` +- `shared/ws-protocol.ts` +- `server/coding-cli/codex-app-server/protocol.ts` +- `server/coding-cli/codex-app-server/client.ts` +- `server/coding-cli/codex-app-server/runtime.ts` +- `server/coding-cli/codex-app-server/launch-planner.ts` +- `server/fresh-agent/runtime-adapter.ts` +- `server/fresh-agent/provider-registry.ts` +- `server/fresh-agent/runtime-manager.ts` +- `server/fresh-agent/router.ts` +- `server/fresh-agent/adapters/claude/normalize.ts` +- `server/fresh-agent/adapters/claude/adapter.ts` +- `server/fresh-agent/adapters/codex/normalize.ts` +- `server/fresh-agent/adapters/codex/adapter.ts` +- `server/index.ts` +- `server/ws-handler.ts` +- `src/lib/api.ts` +- `src/lib/fresh-agent-ws.ts` +- `src/lib/fresh-agent-registry.ts` +- `src/lib/pane-activity.ts` +- `src/lib/session-type-utils.ts` +- `src/lib/tab-registry-snapshot.ts` +- `src/store/freshAgentSlice.ts` +- `src/store/freshAgentThunks.ts` +- `src/store/freshAgentTypes.ts` +- `src/store/paneTypes.ts` +- `src/store/panesSlice.ts` +- `src/store/paneTreeValidation.ts` +- `src/store/selectors/sidebarSelectors.ts` +- `src/store/tabsSlice.ts` +- `src/store/managed-items.ts` +- `src/store/settingsThunks.ts` +- `src/lib/derivePaneTitle.ts` +- `src/lib/session-utils.ts` +- `src/components/ExtensionsView.tsx` +- `src/components/TabsView.tsx` +- `src/components/fresh-agent/FreshAgentView.tsx` +- `src/components/fresh-agent/FreshAgentTranscript.tsx` +- `src/components/fresh-agent/FreshAgentComposer.tsx` +- `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- `src/components/fresh-agent/FreshAgentSidebar.tsx` +- `src/components/HistoryView.tsx` +- `src/components/panes/PaneContainer.tsx` +- `src/components/panes/PanePicker.tsx` +- `src/components/SettingsView.tsx` +- `src/hooks/useKeyboardInset.ts` if main merge introduces it +- `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs` +- `test/unit/server/coding-cli/codex-app-server/client.test.ts` +- `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` +- `test/unit/server/fresh-agent/codex-normalize.test.ts` +- `test/unit/server/fresh-agent/codex-adapter.test.ts` +- `test/unit/server/fresh-agent/claude-normalize.test.ts` +- `test/unit/server/fresh-agent/claude-adapter.test.ts` +- `test/unit/server/fresh-agent/router.test.ts` +- `test/unit/server/fresh-agent/runtime-manager.test.ts` +- `test/unit/server/ws-handler-fresh-agent.test.ts` +- `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` +- `test/e2e-browser/specs/fresh-agent.spec.ts` +- `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +- `test/e2e-browser/perf/scenarios.ts` +- `docs/index.html` + +### Preserve Unless Proven Dead + +- `src/components/agent-chat/*` +- `src/store/agentChatSlice.ts` +- `src/lib/sdk-message-handler.ts` +- Legacy `sdk.*` WebSocket protocol + +These still back Freshclaude behavior and current regression coverage. This plan removes Freshcodex dependence on them, not the entire legacy Claude path. + +## Strategy Gate + +The most important decision is to make the shared contract the center of the architecture before adding more UI. The current branch already has the right shape, but the contract is informal: Codex normalizers pass raw-ish records, client API returns `any`, and `FreshAgentView` infers provider behavior directly. Adding more Freshcodex features on top of that would make every later diff/review/fork/mobile improvement fragile. + +The correct route is: + +- Merge current main first because main contains fixes in exactly the cutover surfaces. +- Split session-type identity from runtime-provider adapter lookup before depending on either in contract tests. `freshclaude` and `kilroy` sharing the Claude adapter must be an intentional many-to-one mapping, not a Map overwrite side effect. +- Lock shared Zod contracts for all read-model payloads and action responses. +- Lock executable traceability for shared fresh-agent schemas and generated Codex schemas before relying on individual examples. The matrix must prove every generated method/request/notification/item/leaf and every shared schema has an owner across parser, normalizer, API/action, UI or supported-negative behavior, fixture, and test. +- Enforce those contracts on both server and client boundaries. +- Replace the temporary `freshAgentSlice` re-export of `agentChatSlice` with a real contract-shaped fresh-agent slice. Freshclaude compatibility can be implemented through the Claude adapter and explicit migration/projection code, not by keeping Fresh-agent state as a renamed agent-chat state tree. +- Replace only Freshcodex's app-server dependency on the experimental websocket transport with a dedicated stdio JSONL rich runtime, while preserving the existing websocket runtime for raw Codex terminal remote attach. Then normalize Codex app-server data fully using app-server generated schemas to avoid guessing method shapes. +- Model every currently documented app-server item and server-request surface before choosing to fail unknown future variants. +- Treat app-server method classification as product scope, not just protocol plumbing. If Freshcodex marks `thread/list`, `thread/loaded/list`, `review/start`, `model/list`, or `modelProvider/capabilities/read` as implemented, the plan must wire those methods into history/session projection, review actions, and settings/capability UI rather than leaving them as unused client helpers. +- Split controller from presentation only after contract fixtures exist. +- Implement Freshcodex actions through app-server thread/turn primitives and explicit server-request response handling. +- Finish transcript virtualization and workspace UX so the foundation is good enough for long-term feature growth, not just a thin demo. Freshcodex must stay page-first for transcript bodies: normal snapshots and body hydration should not load the whole app-server thread. + +No user decision is required. The plan makes one deliberate scope choice: `freshopencode` stays disabled and unimplemented, while the shared contract remains provider-extensible. + +### Task 1: Sync Current Main Without Regressing Fresh-Agent Work + +**Files:** +- Modify as needed by merge: `server/ws-handler.ts` +- Modify as needed by merge: `shared/ws-protocol.ts` +- Modify as needed by merge: `server/coding-cli/codex-app-server/protocol.ts` +- Modify as needed by merge: `server/coding-cli/codex-app-server/runtime.ts` +- Modify as needed by merge: `src/components/agent-chat/AgentChatView.tsx` +- Modify as needed by merge: `src/components/agent-chat/AgentChatSettings.tsx` +- Modify as needed by merge: `src/components/agent-chat/ChatComposer.tsx` +- Modify as needed by merge: `src/components/agent-chat/PermissionBanner.tsx` +- Modify as needed by merge: `src/components/agent-chat/QuestionBanner.tsx` +- Modify as needed by merge: `src/components/agent-chat/ToolStrip.tsx` +- Modify as needed by merge: `src/store/panesSlice.ts` +- Modify as needed by merge: `src/lib/ws-client.ts` +- Modify as needed by merge: `server/title-utils.ts` +- Create or preserve from main: `shared/title-utils.ts` +- Create or preserve from main: `src/hooks/useKeyboardInset.ts` +- Test: `test/unit/client/components/agent-chat/AgentChatView.auto-title.test.tsx` +- Test: `test/unit/client/components/agent-chat/AgentChatView.mobile-keyboard.test.tsx` +- Test: `test/unit/client/components/agent-chat/ChatComposer.mobile.test.tsx` +- Test: `test/unit/client/hooks/useKeyboardInset.test.ts` +- Test: `test/unit/client/store/panesSlice.test.ts` +- Test: `test/unit/server/ws-handler-sdk.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` + +- [ ] **Step 1: Identify the merge conflict surface and baseline expectations** + +Run: + +```bash +git status --short --branch +git log --oneline --left-right --cherry-pick HEAD...origin/main --max-count=30 +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/server/ws-handler-sdk.test.ts +``` + +Expected: record the exact ahead/behind state. If `git status` shows pre-existing unrelated changes, record those paths and do not stage or overwrite them; stop only if they conflict with this task. If the right-side count is nonzero, logs show main commits that must be merged. If the right-side count is zero, `origin/main` is already contained and this task becomes a verification-only sync gate with no merge commit. + +- [ ] **Step 2: Merge `origin/main` into the worktree branch only when needed** + +Run: + +```bash +git fetch origin +git rev-list --left-right --count HEAD...origin/main +``` + +If the right-side count is nonzero, run: + +```bash +git merge origin/main +``` + +Expected: conflicts are possible in `server/ws-handler.ts`, `shared/ws-protocol.ts`, `src/store/panesSlice.ts`, `src/components/agent-chat/AgentChatView.tsx`, and Codex app-server files. If the right-side count is zero, do not run a no-op merge and do not create an empty commit; proceed to Step 4. + +- [ ] **Step 3: Resolve conflicts by preserving both main fixes and fresh-agent behavior** + +Skip this step if Step 2 found no right-side `origin/main` commits. + +Conflict resolution rules: + +```ts +// Keep main's stale-hydration protection in reducers. +// Keep fresh-agent pane normalization and legacy agent-chat migration. +// Keep main's mobile keyboard/touch helpers in agent-chat components. +// Later tasks port those helpers into fresh-agent components. +// Keep main's Codex app-server stdout/stderr drain and initialize contract fixes. +// Keep fresh-agent runtime manager and routes. +``` + +Do not delete fresh-agent tests to make the merge pass. Do not revert main's production static routing, reconnect, stale hydration, or app-server fixes. + +- [ ] **Step 4: Verify the merge** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/server/ws-handler-sdk.test.ts \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/server/ws-handler-fresh-agent.test.ts +``` + +Expected: all pass. If main introduced new tests, include their exact paths from the merge output. + +- [ ] **Step 5: Refactor and verify** + +Tighten only conflict-resolved code. Do not start the freshcodex contract work in this commit. + +Run: + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit only if the main sync changed tracked files** + +If Step 2 found no right-side `origin/main` commits and Step 5 made no tracked changes, do not create an empty commit. Otherwise commit only the files changed by the sync/conflict resolution; do not stage unrelated pre-existing dirty paths. + +```bash +git add \ + server/ws-handler.ts shared/ws-protocol.ts \ + server/coding-cli/codex-app-server/protocol.ts \ + server/coding-cli/codex-app-server/runtime.ts \ + src/store/panesSlice.ts src/lib/ws-client.ts \ + src/components/agent-chat/AgentChatView.tsx \ + src/components/agent-chat/AgentChatSettings.tsx \ + src/components/agent-chat/ChatComposer.tsx \ + src/components/agent-chat/PermissionBanner.tsx \ + src/components/agent-chat/QuestionBanner.tsx \ + src/components/agent-chat/ToolStrip.tsx \ + src/hooks/useKeyboardInset.ts shared/title-utils.ts server/title-utils.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/server/ws-handler-sdk.test.ts +git commit -m "Sync main into freshcodex contract foundation" +``` + +### Task 2: Define The Shared Fresh-Agent Contract + +**Files:** +- Create: `shared/fresh-agent-contract.ts` +- Modify: `shared/fresh-agent.ts` +- Modify: `shared/read-models.ts` +- Test: `test/unit/shared/fresh-agent-contract.test.ts` +- Test: `test/unit/shared/fresh-agent-contract-traceability.test.ts` +- Test: `test/fixtures/fresh-agent/codex/contract-fixtures.ts` +- Test: `test/fixtures/fresh-agent/claude/contract-fixtures.ts` +- Test: `test/fixtures/fresh-agent/contract-traceability.ts` + +- [ ] **Step 1: Write failing contract tests** + +Create tests that require: + +```ts +expect(FreshAgentThreadSnapshotSchema.parse(validCodexSnapshot)).toMatchObject({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + status: 'idle', +}) + +expect(() => FreshAgentThreadSnapshotSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + revision: 1, + status: 'creating', +})).toThrow(/status/i) + +expect(() => FreshAgentTranscriptItemSchema.parse({ + id: 'bad-item', + kind: 'raw', + payload: {}, +})).toThrow(/kind/i) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'user-message-1', + kind: 'message', + role: 'user', + content: [ + { kind: 'text', text: 'Use this mockup', textElements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] }, + { kind: 'image', url: 'https://example.test/mockup.png', mediaType: 'image/png' }, + { kind: 'mention', name: 'README.md', path: '/repo/README.md' }, + { kind: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' }, + ], +})).toMatchObject({ + kind: 'message', + role: 'user', + content: [ + { kind: 'text' }, + { kind: 'image' }, + { kind: 'mention' }, + { kind: 'skill' }, + ], +}) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'agent-message-1', + kind: 'message', + role: 'assistant', + phase: 'final_answer', + memoryCitation: { + entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }], + threadIds: ['thread-memory-1'], + }, + content: [{ kind: 'text', text: 'Done' }], +})).toMatchObject({ + kind: 'message', + phase: 'final_answer', + memoryCitation: expect.objectContaining({ threadIds: ['thread-memory-1'] }), +}) +``` + +Also assert that `FreshAgentTurnPageSchema`, `FreshAgentTurnBodySchema`, `FreshAgentThreadListPageSchema`, `FreshAgentModelListPageSchema`, `FreshAgentModelProviderCapabilitiesSchema`, `FreshAgentActionResultSchema`, `FreshAgentCodexExtensionSchema`, and `FreshAgentClaudeExtensionSchema` parse the new fixtures. The thread-list fixture must preserve `items`, `nextCursor`, and `backwardsCursor` because Codex `thread/list` is paginated and Freshcodex history must not collapse the app-server page to an array. The model-list fixture must preserve `items`, `nextCursor`, and provider-level capabilities when available because Codex `model/list` is paginated and Freshcodex settings must not treat the first page as a complete model catalog or drop `modelProvider/capabilities/read` data at the shared contract boundary. +Also assert that `FreshAgentInputImageSchema`, `FreshAgentRuntimeSettingsSchema`, `FreshAgentCodexRuntimeSettingsSchema`, and `FreshAgentClaudeRuntimeSettingsSchema` parse URL, local-path, data-URL/image-data, model, sandbox, provider-specific permission/approval policy, and provider-specific effort fixtures because those shapes are shared by REST, WebSocket, controller, and adapter code. The broad `FreshAgentRuntimeSettingsSchema` may accept historical Claude and Codex values for persisted data, but the provider-specific schemas are the executable action gate. The test must prove Freshcodex accepts generated Codex effort values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`) and rejects legacy Claude-only effort values such as `max` through `FreshAgentCodexRuntimeSettingsSchema` before an adapter call. It must also prove Freshcodex accepts generated Codex approval policies (`untrusted`, `on-failure`, `on-request`, `never`, and granular policy objects) and rejects Claude permission modes such as `bypassPermissions` through the same provider-specific parser. Add the mirror assertion that Freshclaude still accepts its existing Claude permission/effort values through `FreshAgentClaudeRuntimeSettingsSchema`. +Add `test/fixtures/fresh-agent/contract-traceability.ts` and `test/unit/shared/fresh-agent-contract-traceability.test.ts` before implementing the schemas. The traceability test must enumerate every exported durable schema from `shared/fresh-agent-contract.ts` and require an owner for producer boundary, server parse boundary, client parse boundary, state/persistence boundary when applicable, UI consumer, fixture, and test. It should fail for any exported contract schema that is not listed, and it should fail for any listed schema whose owner/test path is empty. This prevents future shared contract expansion from becoming another implicit, unreviewed surface. + +Include explicit fixtures for every Codex transcript/request surface the user-visible end state names: + +```ts +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'compact-1', + kind: 'context_compaction', + status: 'completed', + summary: 'Compacted prior context', +})).toMatchObject({ kind: 'context_compaction' }) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'dyn-1', + kind: 'dynamic_tool', + name: 'unsupported-local-tool', + namespace: 'fixture-namespace', + status: 'declined', + input: ['non-object', { ok: true }], + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, + reason: 'Dynamic tool calls are not supported by Freshell yet.', +})).toMatchObject({ kind: 'dynamic_tool', status: 'declined', input: ['non-object', { ok: true }], success: false }) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'tool-1', + kind: 'tool', + server: 'fixture-server', + name: 'fixture-tool', + status: 'completed', + input: 'raw-string-argument', + result: { content: [], structuredContent: null, _meta: null }, +})).toMatchObject({ kind: 'tool', input: 'raw-string-argument' }) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'hook-1', + kind: 'hook_prompt', + fragments: [{ text: 'Preflight', hookRunId: 'hook-run-1' }], +})).toMatchObject({ + kind: 'hook_prompt', + fragments: [{ text: 'Preflight', hookRunId: 'hook-run-1' }], +}) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'reasoning-1', + kind: 'reasoning', + summary: ['Checked repository state'], + content: ['First reasoning paragraph', 'Second reasoning paragraph'], +})).toMatchObject({ + kind: 'reasoning', + summary: ['Checked repository state'], + content: ['First reasoning paragraph', 'Second reasoning paragraph'], +}) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'collab-1', + kind: 'collaboration', + tool: 'spawnAgent', + status: 'running', + senderThreadId: 'thread-parent-1', + receiverThreadIds: ['thread-child-1', 'thread-child-2'], + model: 'configured-model', + reasoningEffort: 'high', + agentsStates: { + 'thread-child-1': { status: 'running', message: 'Working' }, + 'thread-child-2': { status: 'completed', message: null }, + }, +})).toMatchObject({ + kind: 'collaboration', + receiverThreadIds: ['thread-child-1', 'thread-child-2'], +}) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'web-1', + kind: 'web_search', + query: 'Freshell Codex', + action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' }, +})).toMatchObject({ + kind: 'web_search', + action: { type: 'findInPage', pattern: 'Codex' }, +}) + +expect(FreshAgentTranscriptItemSchema.parse({ + id: 'image-gen-1', + kind: 'image_generation', + status: 'provider_specific_status', + result: 'https://example.test/generated.png', + savedPath: '/repo/generated.png', +})).toMatchObject({ + kind: 'image_generation', + result: 'https://example.test/generated.png', + savedPath: '/repo/generated.png', +}) + +expect(FreshAgentQuestionRequestSchema.parse({ + requestId: 'server-request-1', + kind: 'mcp_elicitation', + title: 'Confirm MCP input', + prompt: 'Choose a value', + fields: [], +})).toMatchObject({ kind: 'mcp_elicitation' }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'user-input-1', + kind: 'tool_user_input', + answers: { + choice: { answers: ['a'] }, + }, +})).toMatchObject({ + kind: 'tool_user_input', + answers: { choice: { answers: ['a'] } }, +}) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'mcp-elicit-1', + kind: 'mcp_elicitation', + action: 'accept', + content: { value: 'approved' }, + _meta: null, +})).toMatchObject({ kind: 'mcp_elicitation', action: 'accept' }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'permissions-1', + kind: 'permissions_approval', + permissions: { filesystem: { read: true } }, + scope: 'turn', + strictAutoReview: true, +})).toMatchObject({ kind: 'permissions_approval' }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'dynamic-tool-1', + kind: 'dynamic_tool', + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, +})).toMatchObject({ kind: 'dynamic_tool', success: false }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'legacy-exec-1', + kind: 'legacy_exec_approval', + decision: 'approved', +})).toMatchObject({ kind: 'legacy_exec_approval', decision: 'approved' }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 'legacy-patch-1', + kind: 'legacy_patch_approval', + decision: 'denied', +})).toMatchObject({ kind: 'legacy_patch_approval', decision: 'denied' }) + +expect(FreshAgentServerRequestResponseSchema.parse({ + requestId: 42, + kind: 'tool_user_input', + answers: { + choice: { answers: ['a'] }, + }, +})).toMatchObject({ requestId: 42, kind: 'tool_user_input' }) + +expect(() => FreshAgentServerRequestResponseSchema.parse({ + requestId: 42.5, + kind: 'tool_user_input', + answers: { + choice: { answers: ['a'] }, + }, +})).toThrow(/integer/i) + +expect(FreshAgentModelListPageSchema.parse({ + provider: 'codex', + items: [], + nextCursor: null, + providerCapabilities: { + namespaceTools: true, + imageGeneration: false, + webSearch: true, + }, +})).toMatchObject({ + providerCapabilities: { webSearch: true }, +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/shared/fresh-agent-contract.test.ts \ + test/unit/shared/fresh-agent-contract-traceability.test.ts +``` + +Expected: FAIL because `shared/fresh-agent-contract.ts` does not exist. + +- [ ] **Step 3: Implement the schemas** + +Create `shared/fresh-agent-contract.ts` with this shape: + +```ts +import { z } from 'zod' + +export const FreshAgentSessionTypeSchema = z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']) +export const FreshAgentRuntimeProviderSchema = z.enum(['claude', 'codex', 'opencode']) +export const FreshAgentThreadStatusSchema = z.enum(['idle', 'running', 'compacting', 'exited', 'lost', 'error']) +export const FreshAgentRoleSchema = z.enum(['user', 'assistant', 'system']) +export const FreshAgentTurnSourceSchema = z.enum(['durable', 'live']) + +export type FreshAgentSessionType = z.infer<typeof FreshAgentSessionTypeSchema> +export type FreshAgentRuntimeProvider = z.infer<typeof FreshAgentRuntimeProviderSchema> + +const NonEmptyString = z.string().min(1) +const FreshAgentServerRequestIdSchema = z.union([NonEmptyString, z.number().int()]) +const JsonValue: z.ZodType<unknown> = z.lazy(() => z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValue), + z.record(z.string(), JsonValue), +])) + +export const FreshAgentTextElementSchema = z.object({ + byteRange: z.object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }), + placeholder: z.string().nullable(), +}) + +export const FreshAgentMemoryCitationSchema = z.object({ + entries: z.array(z.object({ + path: z.string(), + lineStart: z.number().int().nonnegative(), + lineEnd: z.number().int().nonnegative(), + note: z.string(), + })), + threadIds: z.array(z.string()), +}) + +export const FreshAgentHookPromptFragmentSchema = z.object({ + text: z.string(), + hookRunId: NonEmptyString, +}) + +export const FreshAgentCommandActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('read'), command: z.string(), name: z.string(), path: z.string() }), + z.object({ type: z.literal('listFiles'), command: z.string(), path: z.string().nullable() }), + z.object({ type: z.literal('search'), command: z.string(), query: z.string().nullable(), path: z.string().nullable() }), + z.object({ type: z.literal('unknown'), command: z.string() }), +]) + +export const FreshAgentDynamicToolOutputContentItemSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('inputText'), text: z.string() }), + z.object({ type: z.literal('inputImage'), imageUrl: z.string() }), +]) + +export const FreshAgentWebSearchActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('search'), query: z.string().nullable(), queries: z.array(z.string()).nullable() }), + z.object({ type: z.literal('openPage'), url: z.string().nullable() }), + z.object({ type: z.literal('findInPage'), url: z.string().nullable(), pattern: z.string().nullable() }), + z.object({ type: z.literal('other') }), +]) + +export const FreshAgentTextItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('text'), + text: z.string(), + role: FreshAgentRoleSchema.optional(), +}) + +export const FreshAgentMessageContentPartSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('text'), + text: z.string(), + textElements: z.array(FreshAgentTextElementSchema).default([]), + }), + z.object({ + kind: z.literal('image'), + url: z.string().url().optional(), + path: z.string().optional(), + data: z.string().optional(), + mediaType: z.string().optional(), + alt: z.string().optional(), + }).refine((value) => Boolean(value.url || value.path || value.data), { + message: 'image message content requires url, path, or data', + }), + z.object({ + kind: z.literal('mention'), + name: NonEmptyString, + path: NonEmptyString, + }), + z.object({ + kind: z.literal('skill'), + name: NonEmptyString, + path: NonEmptyString, + }), +]) + +export const FreshAgentMessageItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('message'), + role: FreshAgentRoleSchema, + content: z.array(FreshAgentMessageContentPartSchema).min(1), + phase: z.enum(['commentary', 'final_answer']).nullable().optional(), + memoryCitation: FreshAgentMemoryCitationSchema.nullable().optional(), +}) + +export const FreshAgentReasoningItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('reasoning'), + summary: z.array(z.string()).default([]), + content: z.array(z.string()).default([]), + text: z.string().optional(), +}) + +export const FreshAgentCommandItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('command'), + command: z.string(), + cwd: z.string().optional(), + source: z.enum(['agent', 'userShell', 'unifiedExecStartup', 'unifiedExecInteraction']).optional(), + processId: z.string().nullable().optional(), + status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']), + commandActions: z.array(FreshAgentCommandActionSchema).default([]), + output: z.string().optional(), + exitCode: z.number().int().optional(), + durationMs: z.number().nonnegative().optional(), +}) + +export const FreshAgentFileChangeItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('file_change'), + status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']), + changes: z.array(z.object({ + path: NonEmptyString, + changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']), + movePath: z.string().nullable().optional(), + diff: z.string().optional(), + })), +}) + +export const FreshAgentToolItemSchema = z.object({ + id: NonEmptyString, + kind: z.literal('tool'), + server: z.string().optional(), + name: NonEmptyString, + status: z.enum(['pending', 'running', 'completed', 'failed']), + input: JsonValue.optional(), + mcpAppResourceUri: z.string().optional(), + result: JsonValue.optional(), + error: z.string().optional(), + durationMs: z.number().nonnegative().optional(), +}) + +export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ + FreshAgentMessageItemSchema, + FreshAgentTextItemSchema, + FreshAgentReasoningItemSchema, + FreshAgentCommandItemSchema, + FreshAgentFileChangeItemSchema, + FreshAgentToolItemSchema, + z.object({ id: NonEmptyString, kind: z.literal('plan'), text: z.string(), status: z.enum(['pending', 'running', 'completed']).optional() }), + z.object({ id: NonEmptyString, kind: z.literal('review'), phase: z.enum(['entered', 'exited']), label: z.string().optional(), text: z.string().optional() }), + z.object({ id: NonEmptyString, kind: z.literal('web_search'), query: z.string(), action: FreshAgentWebSearchActionSchema.nullable().optional(), status: z.enum(['pending', 'running', 'completed', 'failed']).optional() }), + z.object({ id: NonEmptyString, kind: z.literal('hook_prompt'), fragments: z.array(FreshAgentHookPromptFragmentSchema).default([]), text: z.string().optional() }), + z.object({ id: NonEmptyString, kind: z.literal('image'), path: z.string().optional(), url: z.string().optional(), alt: z.string().optional() }), + z.object({ + id: NonEmptyString, + kind: z.literal('image_generation'), + prompt: z.string().optional(), + status: z.string().optional(), + displayStatus: z.enum(['pending', 'running', 'completed', 'failed']).optional(), + result: z.string().optional(), + imageUrl: z.string().optional(), + savedPath: z.string().optional(), + path: z.string().optional(), + }), + z.object({ + id: NonEmptyString, + kind: z.literal('collaboration'), + tool: NonEmptyString, + status: z.enum(['pending', 'running', 'completed', 'failed']), + senderThreadId: z.string().optional(), + receiverThreadIds: z.array(z.string()).default([]), + prompt: z.string().nullable().optional(), + model: z.string().nullable().optional(), + reasoningEffort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).nullable().optional(), + agentsStates: z.record(z.string(), JsonValue).default({}), + }), + z.object({ id: NonEmptyString, kind: z.literal('context_compaction'), status: z.enum(['pending', 'running', 'completed', 'failed', 'deprecated']), summary: z.string().optional(), beforeTokens: z.number().int().nonnegative().optional(), afterTokens: z.number().int().nonnegative().optional() }), + z.object({ id: NonEmptyString, kind: z.literal('dynamic_tool'), name: NonEmptyString, namespace: z.string().nullable().optional(), status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']), input: JsonValue.optional(), contentItems: z.array(FreshAgentDynamicToolOutputContentItemSchema).nullable().optional(), success: z.boolean().nullable().optional(), durationMs: z.number().nonnegative().nullable().optional(), result: JsonValue.optional(), reason: z.string().optional(), error: z.string().optional() }), + z.object({ id: NonEmptyString, kind: z.literal('request_prompt'), requestId: FreshAgentServerRequestIdSchema, requestKind: z.enum(['approval', 'question', 'mcp_elicitation', 'dynamic_tool', 'auth_refresh']), title: z.string().optional(), prompt: z.string().optional(), status: z.enum(['pending', 'resolved', 'declined']) }), + z.object({ id: NonEmptyString, kind: z.literal('error'), message: z.string(), code: z.string().optional() }), +]) + +export const FreshAgentTurnBodySchema = z.object({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + threadId: NonEmptyString, + turnId: NonEmptyString, + revision: z.number().int().nonnegative(), + ordinal: z.number().int().nonnegative().optional(), + source: FreshAgentTurnSourceSchema.optional(), + summary: z.string().optional(), + startedAt: z.string().optional(), + completedAt: z.string().optional(), + role: FreshAgentRoleSchema.optional(), // legacy compatibility only; Codex role lives on message items + items: z.array(FreshAgentTranscriptItemSchema), +}) + +export const FreshAgentTurnSummarySchema = FreshAgentTurnBodySchema.omit({ items: true }).extend({ + itemCount: z.number().int().nonnegative().default(0), + preview: z.string().optional(), + body: FreshAgentTurnBodySchema.optional(), +}) + +export const FreshAgentTurnPageSchema = z.object({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + threadId: NonEmptyString, + revision: z.number().int().nonnegative(), + turns: z.array(FreshAgentTurnSummarySchema), + nextCursor: z.string().nullable(), + backwardsCursor: z.string().nullable(), +}) + +export const FreshAgentSessionSummarySchema = z.object({ + sessionId: NonEmptyString, + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + runtimeProvider: FreshAgentRuntimeProviderSchema.optional(), + title: z.string().optional(), + summary: z.string().optional(), + cwd: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + source: JsonValue.optional(), + archived: z.boolean().optional(), + parentThreadId: z.string().nullable().optional(), +}) + +export const FreshAgentThreadListPageSchema = z.object({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + items: z.array(FreshAgentSessionSummarySchema), + nextCursor: z.string().nullable(), + backwardsCursor: z.string().nullable(), +}) + +export const FreshAgentCodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) + +export const FreshAgentModelSummarySchema = z.object({ + id: NonEmptyString, + model: NonEmptyString, + displayName: z.string(), + description: z.string(), + hidden: z.boolean(), + isDefault: z.boolean(), + defaultReasoningEffort: FreshAgentCodexReasoningEffortSchema, + supportedReasoningEfforts: z.array(z.object({ + reasoningEffort: FreshAgentCodexReasoningEffortSchema, + description: z.string(), + })).default([]), + inputModalities: z.array(z.enum(['text', 'image'])).default([]), + supportsPersonality: z.boolean().default(false), + additionalSpeedTiers: z.array(z.string()).default([]), +}) + +export const FreshAgentModelProviderCapabilitiesSchema = z.object({ + namespaceTools: z.boolean(), + imageGeneration: z.boolean(), + webSearch: z.boolean(), +}) + +export const FreshAgentModelListPageSchema = z.object({ + provider: z.literal('codex'), + items: z.array(FreshAgentModelSummarySchema), + nextCursor: z.string().nullable(), + providerCapabilities: FreshAgentModelProviderCapabilitiesSchema.optional(), +}) + +const BooleanQueryParam = z.union([ + z.boolean(), + z.enum(['true', 'false']).transform((value) => value === 'true'), +]) + +export const FreshAgentModelListQuerySchema = z.object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().positive().max(200).optional(), + includeHidden: BooleanQueryParam.optional(), +}) + +export const FreshAgentTurnPageQuerySchema = z.object({ + cursor: z.string().min(1).optional(), + priority: z.enum(['visible', 'background']).optional(), + revision: z.coerce.number().int().nonnegative(), + limit: z.coerce.number().int().positive().max(200).optional(), + sortDirection: z.enum(['asc', 'desc']).optional(), +}) + +export const FreshAgentCapabilitiesSchema = z.object({ + send: z.boolean(), + interrupt: z.boolean(), + approvals: z.boolean(), + questions: z.boolean(), + fork: z.boolean(), + review: z.boolean(), + worktrees: z.boolean(), + diffs: z.boolean(), + childThreads: z.boolean(), + turnPaging: z.boolean(), + turnBodies: z.boolean(), +}) + +export const FreshAgentReviewTargetSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('uncommittedChanges') }), + z.object({ type: z.literal('baseBranch'), branch: NonEmptyString }), + z.object({ type: z.literal('commit'), sha: NonEmptyString, title: z.string().nullable().optional() }), + z.object({ type: z.literal('custom'), instructions: NonEmptyString }), +]) + +export const FreshAgentInputImageSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('url'), url: z.string().url(), mediaType: z.string().optional() }), + z.object({ kind: z.literal('local'), path: z.string().min(1), mediaType: z.string().optional() }), + z.object({ kind: z.literal('data'), data: z.string().min(1), mediaType: z.string().min(1) }), +]) + +export const FreshAgentLegacyClaudeEffortSchema = z.enum(['low', 'medium', 'high', 'max']) +export const FreshAgentCodexApprovalPolicySchema = z.union([ + z.enum(['untrusted', 'on-failure', 'on-request', 'never']), + z.object({ + granular: z.object({ + sandbox_approval: z.boolean(), + rules: z.boolean(), + skill_approval: z.boolean().optional().default(false), + request_permissions: z.boolean().optional().default(false), + mcp_elicitations: z.boolean(), + }), + }), +]) +export const FreshAgentLegacyClaudePermissionModeSchema = z.enum(['default', 'plan', 'acceptEdits', 'bypassPermissions']) + +const FreshAgentRuntimeSettingsBaseSchema = z.object({ + model: z.string().min(1).optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), +}) + +export const FreshAgentCodexRuntimeSettingsSchema = FreshAgentRuntimeSettingsBaseSchema.extend({ + permissionMode: FreshAgentCodexApprovalPolicySchema.optional(), + effort: FreshAgentCodexReasoningEffortSchema.optional(), +}) + +export const FreshAgentClaudeRuntimeSettingsSchema = FreshAgentRuntimeSettingsBaseSchema.extend({ + permissionMode: FreshAgentLegacyClaudePermissionModeSchema.optional(), + effort: FreshAgentLegacyClaudeEffortSchema.optional(), +}) + +export const FreshAgentRuntimeSettingsSchema = z.object({ + model: z.string().min(1).optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + // Historical field name retained for persisted pane/settings compatibility only. + // Executable actions must call parseFreshAgentRuntimeSettingsForProvider after + // resolving the provider so Freshcodex cannot accept Claude-only values. + permissionMode: z.union([FreshAgentCodexApprovalPolicySchema, FreshAgentLegacyClaudePermissionModeSchema]).optional(), + effort: z.union([FreshAgentCodexReasoningEffortSchema, FreshAgentLegacyClaudeEffortSchema]).optional(), +}) + +export function parseFreshAgentRuntimeSettingsForProvider( + provider: FreshAgentRuntimeProvider, + value: unknown | undefined, +): FreshAgentRuntimeSettings | undefined { + if (value === undefined) return undefined + switch (provider) { + case 'codex': + return FreshAgentCodexRuntimeSettingsSchema.parse(value) + case 'claude': + return FreshAgentClaudeRuntimeSettingsSchema.parse(value) + case 'opencode': + throw new Error('Freshopencode runtime settings are not implemented') + } +} + +export const FreshAgentThreadSnapshotSchema = z.object({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + threadId: NonEmptyString, + revision: z.number().int().nonnegative(), + status: FreshAgentThreadStatusSchema, + summary: z.string().optional(), + capabilities: FreshAgentCapabilitiesSchema, + tokenUsage: z.object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + cachedTokens: z.number().int().nonnegative().optional(), + totalTokens: z.number().int().nonnegative(), + contextTokens: z.number().int().nonnegative().optional(), + compactPercent: z.number().nonnegative().optional(), + compactThresholdTokens: z.number().int().nonnegative().optional(), + }).optional(), + initialTurnPage: FreshAgentTurnPageSchema.optional(), + pendingApprovals: z.array(FreshAgentApprovalRequestSchema).default([]), + pendingQuestions: z.array(FreshAgentQuestionRequestSchema).default([]), + worktrees: z.array(FreshAgentWorktreeRefSchema).default([]), + diffs: z.array(FreshAgentDiffRefSchema).default([]), + childThreads: z.array(FreshAgentChildThreadRefSchema).default([]), + extensions: FreshAgentExtensionsSchema.default({}), +}) +``` + +Define the referenced approval, question, worktree, diff, child-thread, Claude extension, Codex extension, and action-result schemas in the same file. In the actual implementation file, place these definitions before `FreshAgentThreadSnapshotSchema` or use `z.lazy` for any recursive reference. Do not leave these as implicit shapes for the executor to invent; they are part of the shared contract boundary and must be parsed by server, client API, Redux, and UI tests. + +```ts +export const FreshAgentApprovalRequestSchema = z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.enum(['command', 'file_change', 'permissions', 'legacy_exec', 'legacy_patch']), + title: z.string(), + prompt: z.string().optional(), + threadId: z.string().optional(), + turnId: z.string().nullable().optional(), + itemId: z.string().nullable().optional(), + command: z.string().optional(), + cwd: z.string().optional(), + fileChanges: z.array(z.object({ + path: NonEmptyString, + changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']), + movePath: z.string().nullable().optional(), + diff: z.string().optional(), + })).default([]), + permissions: z.object({ + network: JsonValue.optional(), + fileSystem: JsonValue.optional(), + }).optional(), + reason: z.string().nullable().optional(), + status: z.enum(['pending', 'resolved', 'declined']).default('pending'), +}) + +export const FreshAgentQuestionFieldSchema = z.object({ + id: NonEmptyString, + label: z.string(), + prompt: z.string().optional(), + type: z.enum(['text', 'single_select', 'multi_select', 'boolean', 'number', 'json']).default('text'), + options: z.array(z.object({ + value: z.string(), + label: z.string(), + description: z.string().optional(), + })).default([]), + required: z.boolean().default(false), + schema: JsonValue.optional(), +}) + +export const FreshAgentQuestionRequestSchema = z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.enum(['tool_user_input', 'mcp_elicitation', 'auth_refresh']), + title: z.string(), + prompt: z.string().optional(), + threadId: z.string().optional(), + turnId: z.string().nullable().optional(), + itemId: z.string().nullable().optional(), + fields: z.array(FreshAgentQuestionFieldSchema).default([]), + status: z.enum(['pending', 'resolved', 'declined']).default('pending'), +}) + +export const FreshAgentWorktreeRefSchema = z.object({ + id: NonEmptyString, + path: NonEmptyString, + cwd: z.string().optional(), + branch: z.string().nullable().optional(), + baseBranch: z.string().nullable().optional(), + headSha: z.string().nullable().optional(), + dirty: z.boolean().optional(), + active: z.boolean().default(false), +}) + +export const FreshAgentDiffFileSchema = z.object({ + path: NonEmptyString, + changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']), + movePath: z.string().nullable().optional(), + additions: z.number().int().nonnegative().optional(), + deletions: z.number().int().nonnegative().optional(), + diff: z.string().optional(), +}) + +export const FreshAgentDiffRefSchema = z.object({ + id: NonEmptyString, + title: z.string(), + status: z.enum(['pending', 'running', 'completed', 'failed']).optional(), + files: z.array(FreshAgentDiffFileSchema).default([]), + reviewThreadId: z.string().nullable().optional(), + summary: z.string().optional(), +}) + +export const FreshAgentChildThreadRefSchema = z.object({ + threadId: NonEmptyString, + parentThreadId: z.string().nullable().optional(), + title: z.string().optional(), + status: FreshAgentThreadStatusSchema.optional(), + source: JsonValue.optional(), + depth: z.number().int().nonnegative().optional(), + agentNickname: z.string().nullable().optional(), + agentRole: z.string().nullable().optional(), + model: z.string().nullable().optional(), + reasoningEffort: FreshAgentCodexReasoningEffortSchema.nullable().optional(), +}) + +export const FreshAgentCodexExtensionSchema = z.object({ + model: z.string().nullable().optional(), + modelProvider: z.string().nullable().optional(), + serviceTier: z.enum(['fast', 'flex']).nullable().optional(), + cwd: z.string().nullable().optional(), + cliVersion: z.string().nullable().optional(), + source: JsonValue.optional(), + gitInfo: JsonValue.nullable().optional(), + forkedFromId: z.string().nullable().optional(), + activeFlags: z.array(z.enum(['waitingOnApproval', 'waitingOnUserInput'])).default([]), + approvalsReviewer: z.enum(['user', 'auto_review', 'guardian_subagent']).nullable().optional(), + sandbox: JsonValue.optional(), + approvalPolicy: JsonValue.optional(), + review: z.object({ + reviewThreadId: z.string().nullable().optional(), + target: FreshAgentReviewTargetSchema.optional(), + delivery: z.enum(['inline', 'detached']).optional(), + status: z.enum(['pending', 'running', 'completed', 'failed']).optional(), + summary: z.string().optional(), + }).optional(), +}) + +export const FreshAgentClaudeExtensionSchema = z.object({ + sessionFile: z.string().nullable().optional(), + projectPath: z.string().nullable().optional(), + permissionMode: FreshAgentLegacyClaudePermissionModeSchema.optional(), + effort: FreshAgentLegacyClaudeEffortSchema.optional(), +}) + +export const FreshAgentExtensionsSchema = z.object({ + codex: FreshAgentCodexExtensionSchema.optional(), + claude: FreshAgentClaudeExtensionSchema.optional(), +}) + +export const FreshAgentActionResultSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('send'), sessionId: NonEmptyString, turnId: z.string().optional() }), + z.object({ type: z.literal('interrupt'), sessionId: NonEmptyString, interrupted: z.boolean() }), + z.object({ type: z.literal('fork'), sourceSessionId: NonEmptyString, session: FreshAgentSessionSummarySchema, parentThreadId: z.string().nullable().optional() }), + z.object({ type: z.literal('review_start'), sessionId: NonEmptyString, turnId: NonEmptyString, reviewThreadId: NonEmptyString, target: FreshAgentReviewTargetSchema, delivery: z.enum(['inline', 'detached']) }), + z.object({ type: z.literal('server_request_response'), sessionId: NonEmptyString, requestId: FreshAgentServerRequestIdSchema, responseKind: z.string() }), + z.object({ type: z.literal('kill'), sessionId: NonEmptyString, success: z.boolean() }), + z.object({ type: z.literal('error'), code: NonEmptyString, message: z.string(), retryable: z.boolean().optional() }), +]) +``` + +Export inferred types for every schema. Keep provider extension schemas typed and narrow. + +Also define a generated-shape-preserving response schema for pending server requests. This schema is the shared surface used by WebSocket actions, controller props, and the Codex adapter when it responds on the original JSON-RPC server request id: + +```ts +export const FreshAgentToolUserInputAnswerSchema = z.object({ + answers: z.array(z.string()), +}) + +export const FreshAgentMcpElicitationActionSchema = z.enum(['accept', 'decline', 'cancel']) + +export const FreshAgentLegacyCodexReviewDecisionSchema = z.union([ + z.enum(['approved', 'approved_for_session', 'denied', 'timed_out', 'abort']), + z.object({ + approved_execpolicy_amendment: z.object({ + proposed_execpolicy_amendment: z.record(z.string(), JsonValue), + }), + }), + z.object({ + network_policy_amendment: z.object({ + network_policy_amendment: z.record(z.string(), JsonValue), + }), + }), +]) + +export const FreshAgentServerRequestResponseSchema = z.discriminatedUnion('kind', [ + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('command_approval'), + decision: z.union([ + z.enum(['accept', 'acceptForSession', 'decline', 'cancel']), + z.record(z.string(), JsonValue), + ]), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('file_change_approval'), + decision: z.enum(['accept', 'acceptForSession', 'decline', 'cancel']), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('permissions_approval'), + permissions: z.record(z.string(), JsonValue), + scope: z.enum(['turn', 'session']), + strictAutoReview: z.boolean().optional(), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('tool_user_input'), + answers: z.record(z.string(), FreshAgentToolUserInputAnswerSchema), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('mcp_elicitation'), + action: FreshAgentMcpElicitationActionSchema, + content: JsonValue.nullable(), + _meta: JsonValue.nullable(), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('dynamic_tool'), + contentItems: z.array(FreshAgentDynamicToolOutputContentItemSchema), + success: z.boolean(), + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('legacy_exec_approval'), + decision: FreshAgentLegacyCodexReviewDecisionSchema, + }), + z.object({ + requestId: FreshAgentServerRequestIdSchema, + kind: z.literal('legacy_patch_approval'), + decision: FreshAgentLegacyCodexReviewDecisionSchema, + }), +]) +``` + +The implementation may narrow `permissions`, `scope`, MCP `content`, dynamic-tool output content, and legacy review-decision amendment payloads further when the Codex generated response schemas are modeled in `server/coding-cli/codex-app-server/protocol.ts`, but the shared action contract must not reduce them to strings or Claude-style answers. Even when Freshcodex auto-declines unsupported dynamic tool calls without user input, the response shape must stay contract-modeled so tests can prove the app-server turn is unblocked with the generated `DynamicToolCallResponse` envelope. Legacy root `applyPatchApproval` and `execCommandApproval` must likewise stay distinguishable from v2 command/file approvals because their generated decision enum uses root `ReviewDecision`, not v2 `accept` / `decline` values. + +Define referenced schemas before any schema that uses them, or wrap recursive references in `z.lazy`, so module evaluation cannot hit a temporal-dead-zone `ReferenceError`. + +```ts +export type FreshAgentThreadSnapshot = z.infer<typeof FreshAgentThreadSnapshotSchema> +export type FreshAgentTurnPage = z.infer<typeof FreshAgentTurnPageSchema> +export type FreshAgentTurnBody = z.infer<typeof FreshAgentTurnBodySchema> +export type FreshAgentTranscriptItem = z.infer<typeof FreshAgentTranscriptItemSchema> +export type FreshAgentThreadListPage = z.infer<typeof FreshAgentThreadListPageSchema> +export type FreshAgentModelSummary = z.infer<typeof FreshAgentModelSummarySchema> +export type FreshAgentModelProviderCapabilities = z.infer<typeof FreshAgentModelProviderCapabilitiesSchema> +export type FreshAgentModelListPage = z.infer<typeof FreshAgentModelListPageSchema> +export type FreshAgentModelListQuery = z.infer<typeof FreshAgentModelListQuerySchema> +export type FreshAgentCodexRuntimeSettings = z.infer<typeof FreshAgentCodexRuntimeSettingsSchema> +export type FreshAgentClaudeRuntimeSettings = z.infer<typeof FreshAgentClaudeRuntimeSettingsSchema> +export type FreshAgentRuntimeSettings = z.infer<typeof FreshAgentRuntimeSettingsSchema> +export type FreshAgentServerRequestId = z.infer<typeof FreshAgentServerRequestIdSchema> +export type FreshAgentServerRequestResponse = z.infer<typeof FreshAgentServerRequestResponseSchema> +``` + +Create `test/fixtures/fresh-agent/contract-traceability.ts` with a small, explicit data shape so completeness is testable: + +```ts +export type FreshAgentContractTraceabilityEntry = { + schemaName: string + producerBoundary: string + serverParser: string + clientParser: string + stateOwner: string + persistenceOwner: string | null + uiConsumer: string + fixtureOwner: string + testOwner: string + notes?: string +} + +export const freshAgentContractTraceability: FreshAgentContractTraceabilityEntry[] = [ + { + schemaName: 'FreshAgentThreadSnapshotSchema', + producerBoundary: 'server/fresh-agent/runtime-manager.ts', + serverParser: 'FreshAgentThreadSnapshotSchema.parse', + clientParser: 'src/lib/api.ts parseFreshAgentThreadSnapshot', + stateOwner: 'src/store/freshAgentSlice.ts', + persistenceOwner: null, + uiConsumer: 'src/components/fresh-agent/useFreshAgentThreadController.ts', + fixtureOwner: 'test/fixtures/fresh-agent/codex/contract-fixtures.ts', + testOwner: 'test/unit/shared/fresh-agent-contract.test.ts', + }, + // Replace this comment with one entry for every exported durable FreshAgent*Schema before committing. +] +``` + +The test should compute exported durable schema names from `shared/fresh-agent-contract.ts` text and compare them to `freshAgentContractTraceability.map((entry) => entry.schemaName)`. Exclude private helper schemas that are not exported, but do not exclude exported schemas merely because only one provider currently uses them. The placeholder comment above must not survive the task; the traceability test must fail if any entry field is blank or if any exported durable schema lacks an entry. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/shared/fresh-agent-contract.test.ts \ + test/unit/shared/fresh-agent-contract-traceability.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Move any duplicate provider/session enum literals from `shared/fresh-agent.ts` into imports from `shared/fresh-agent-contract.ts` where that reduces duplication without creating circular imports. + +Run: + +```bash +npm run test:vitest -- \ + test/unit/shared/fresh-agent-contract.test.ts \ + test/unit/shared/fresh-agent-contract-traceability.test.ts \ + test/unit/shared/fresh-agent-registry.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + shared/fresh-agent-contract.ts shared/fresh-agent.ts shared/read-models.ts \ + test/unit/shared/fresh-agent-contract.test.ts \ + test/unit/shared/fresh-agent-contract-traceability.test.ts \ + test/fixtures/fresh-agent/codex/contract-fixtures.ts \ + test/fixtures/fresh-agent/claude/contract-fixtures.ts \ + test/fixtures/fresh-agent/contract-traceability.ts +git commit -m "Add strict fresh-agent read-model contracts" +``` + +### Task 3: Enforce Contracts At Server And Client Boundaries + +**Files:** +- Modify: `server/index.ts` +- Modify: `server/fresh-agent/runtime-adapter.ts` +- Modify: `server/fresh-agent/provider-registry.ts` +- Modify: `server/fresh-agent/runtime-manager.ts` +- Modify: `server/fresh-agent/router.ts` +- Modify: `server/fresh-agent/adapters/claude/normalize.ts` +- Modify: `server/fresh-agent/adapters/codex/normalize.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/fresh-agent-ws.ts` +- Modify: `src/store/freshAgentSlice.ts` +- Modify: `src/store/freshAgentThunks.ts` +- Modify: `src/store/freshAgentTypes.ts` +- Modify: `src/lib/pane-activity.ts` +- Create: `src/lib/fresh-agent-api-error.ts` +- Test: `test/unit/server/fresh-agent/contract-boundary.test.ts` +- Test: `test/unit/server/fresh-agent/provider-registry.test.ts` +- Test: `test/unit/server/fresh-agent/router.test.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` +- Test: `test/unit/client/lib/api.fresh-agent-contract.test.ts` +- Test: `test/unit/client/store/freshAgentSlice.test.ts` +- Test: `test/unit/client/lib/pane-activity.test.ts` +- Test: `test/unit/server/fresh-agent/claude-normalize.test.ts` +- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts` + +- [ ] **Step 1: Write failing boundary tests** + +Add tests for these cases: + +```ts +it('rejects invalid adapter snapshots with a clear contract error', async () => { + const manager = new FreshAgentRuntimeManager({ registry: registryReturningInvalidSnapshot }) + await expect(manager.getSnapshot({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' })) + .rejects.toMatchObject({ code: 'FRESH_AGENT_CONTRACT_INVALID' }) +}) + +it('returns 502 when adapter output violates the fresh-agent contract', async () => { + const response = await request(app).get('/api/fresh-agent/threads/freshcodex/codex/thread-1') + expect(response.status).toBe(502) + expect(response.body.code).toBe('FRESH_AGENT_CONTRACT_INVALID') +}) + +it('does not expose provider-only fresh-agent thread routes', async () => { + const response = await request(app).get('/api/fresh-agent/threads/codex/thread-1') + expect([400, 404]).toContain(response.status) +}) + +it('surfaces a controlled client load error for invalid snapshot payloads', async () => { + mockFetchJson({ provider: 'codex', status: 'creating' }) + await expect(getFreshAgentThreadSnapshot('freshcodex', 'codex', 'thread-1')) + .rejects.toMatchObject({ code: 'FRESH_AGENT_CONTRACT_INVALID' }) +}) + +it('keeps session-type identity separate from runtime adapter lookup', () => { + const registry = createFreshAgentProviderRegistry({ + sessionTypes: [ + { sessionType: 'freshclaude', runtimeProvider: 'claude', label: 'Freshclaude' }, + { sessionType: 'kilroy', runtimeProvider: 'claude', label: 'Kilroy', hidden: true }, + { sessionType: 'freshcodex', runtimeProvider: 'codex', label: 'Freshcodex' }, + ], + runtimeAdapters: [ + { runtimeProvider: 'claude', adapter: claudeAdapter }, + { runtimeProvider: 'codex', adapter: codexAdapter }, + ], + }) + expect(registry.resolveBySessionType('freshclaude')?.adapter).toBe(claudeAdapter) + expect(registry.resolveBySessionType('kilroy')?.adapter).toBe(claudeAdapter) + expect(registry.resolveByRuntimeProvider('claude')?.adapter).toBe(claudeAdapter) +}) + +it('freshAgentSlice is independent from legacy agentChatSlice', () => { + expect(freshAgentReducer).not.toBe(agentChatReducer) + const state = freshAgentReducer(undefined, freshAgentSnapshotReceived(validCodexSnapshot)) + const key = makeFreshAgentSessionKey({ + sessionType: 'freshcodex', + provider: 'codex', + sessionId: 'thread-codex-1', + }) + expect(state.sessions[key]).toMatchObject({ + sessionType: 'freshcodex', + provider: 'codex', + }) +}) + +it('freshAgentSlice keeps colliding opaque ids separate by full locator', () => { + const codexSnapshot = { ...validCodexSnapshot, sessionType: 'freshcodex', provider: 'codex', threadId: 'shared-thread-id' } + const claudeSnapshot = { ...validClaudeSnapshot, sessionType: 'freshclaude', provider: 'claude', threadId: 'shared-thread-id' } + let state = freshAgentReducer(undefined, freshAgentSnapshotReceived(codexSnapshot)) + state = freshAgentReducer(state, freshAgentSnapshotReceived(claudeSnapshot)) + expect(Object.keys(state.sessions)).toEqual(expect.arrayContaining([ + 'freshcodex:codex:shared-thread-id', + 'freshclaude:claude:shared-thread-id', + ])) +}) + +it('freshAgentThunks and activity projection do not read fresh-agent state through agent-chat bridges', () => { + expect(String(loadFreshAgentTurnBody.typePrefix)).toMatch(/^freshAgent\//) + const activity = resolvePaneActivity({ + paneId: 'pane-1', + content: { kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1', createRequestId: 'req-1', status: 'running' }, + isOnlyPane: true, + codexActivityByTerminalId: {}, + opencodeActivityByTerminalId: {}, + paneRuntimeActivityByPaneId: {}, + agentChatSessions: {}, + freshAgentSessions: { + [makeFreshAgentSessionKey({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1' })]: { + sessionType: 'freshcodex', + provider: 'codex', + status: 'running', + }, + }, + }) + expect(activity).toEqual({ isBusy: true, source: 'fresh-agent' }) +}) + +it('routes fresh-agent actions by the full session locator instead of bare session id', async () => { + const manager = new FreshAgentRuntimeManager({ registry }) + manager.attach({ sessionType: 'freshclaude', provider: 'claude', sessionId: 'shared-thread-id' }) + manager.attach({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'shared-thread-id' }) + await manager.send({ + sessionType: 'freshcodex', + provider: 'codex', + sessionId: 'shared-thread-id', + }, { text: 'Route to Codex' }) + expect(codexAdapter.send).toHaveBeenCalledWith('shared-thread-id', expect.objectContaining({ text: 'Route to Codex' })) + expect(claudeAdapter.send).not.toHaveBeenCalled() +}) + +it('rejects action messages whose locator does not match the attached session record', async () => { + const manager = new FreshAgentRuntimeManager({ registry }) + manager.attach({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1' }) + await expect(manager.send({ + sessionType: 'freshclaude', + provider: 'claude', + sessionId: 'thread-1', + }, { text: 'Wrong runtime' })).rejects.toMatchObject({ code: 'FRESH_AGENT_SESSION_LOCATOR_MISMATCH' }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/fresh-agent/contract-boundary.test.ts \ + test/unit/server/fresh-agent/provider-registry.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/client/lib/api.fresh-agent-contract.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/server/fresh-agent/claude-normalize.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts +``` + +Expected: FAIL because boundary parsing is not implemented, client helpers return `any`, provider lookup still conflates session type with runtime provider, fresh-agent state/thunks are still legacy agent-chat aliases, and fresh-agent pane activity still reads through `agentChatSessions`. + +- [ ] **Step 3: Implement boundary parsing** + +In `server/fresh-agent/runtime-adapter.ts`, replace `unknown` read-model returns: + +```ts +import type { + FreshAgentThreadSnapshot, + FreshAgentTurnBody, + FreshAgentTurnPage, +} from '../../shared/fresh-agent-contract.js' + +getSnapshot?(thread: FreshAgentThreadLocator, revision?: number): Promise<FreshAgentThreadSnapshot> +getTurnPage?(thread: FreshAgentThreadLocator, query: FreshAgentTurnPageQuery): Promise<FreshAgentTurnPage> +getTurnBody?(thread: FreshAgentThreadLocator & { turnId: string }, revision: number): Promise<FreshAgentTurnBody> +``` + +`FreshAgentThreadLocator` should be `{ sessionType: FreshAgentSessionType; provider: FreshAgentRuntimeProvider; threadId: string }`, not provider/thread id only. + +In `server/fresh-agent/provider-registry.ts`, split the registry inputs: + +```ts +type FreshAgentSessionTypeRegistration = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + label: string + hidden?: boolean + disabled?: boolean +} + +type FreshAgentRuntimeAdapterRegistration = { + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter +} +``` + +`resolveBySessionType(sessionType)` should return the matching session descriptor plus the adapter registered for that descriptor's runtime provider. `resolveByRuntimeProvider(provider)` should return the adapter registered for that provider without depending on whichever session type was registered last. Add an invariant test that `freshclaude` and `kilroy` both resolve to the Claude adapter and cannot overwrite each other. + +Update every registry construction site in the same task. In `server/index.ts`, replace the current array of combined registrations with separate session descriptors and runtime adapters: + +```ts +const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ + registry: createFreshAgentProviderRegistry({ + sessionTypes: [ + { sessionType: 'freshclaude', runtimeProvider: 'claude', label: 'Freshclaude' }, + { sessionType: 'kilroy', runtimeProvider: 'claude', label: 'Kilroy', hidden: true }, + { sessionType: 'freshcodex', runtimeProvider: 'codex', label: 'Freshcodex' }, + ], + runtimeAdapters: [ + { runtimeProvider: 'claude', adapter: claudeFreshAgentAdapter }, + { runtimeProvider: 'codex', adapter: codexFreshAgentAdapter }, + ], + }), +}) +``` + +Update existing runtime-manager tests that construct the registry so Task 3 remains typecheckable on its own. Do not add a legacy overload that accepts the old combined array; that would keep the ambiguous many-session-to-one-provider model alive. + +In a shared fresh-agent locator module or in `shared/fresh-agent-contract.ts`, export the canonical locator key helper so server runtime state, Redux state, pane activity, and tests cannot drift: + +```ts +export type FreshAgentSessionLocator = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId: string +} + +export type FreshAgentSessionKey = `${FreshAgentSessionType}:${FreshAgentRuntimeProvider}:${string}` + +export function makeFreshAgentSessionKey(locator: FreshAgentSessionLocator): FreshAgentSessionKey { + return `${locator.sessionType}:${locator.provider}:${locator.sessionId}` +} +``` + +In `runtime-manager.ts`, import that helper and add: + +```ts + +export class FreshAgentSessionLocatorMismatchError extends Error { + readonly code = 'FRESH_AGENT_SESSION_LOCATOR_MISMATCH' as const +} + +export class FreshAgentContractValidationError extends Error { + readonly code = 'FRESH_AGENT_CONTRACT_INVALID' as const + constructor(readonly surface: string, readonly issues: unknown) { + super(`Fresh-agent ${surface} violated the shared contract`) + } +} + +function parseSnapshot(value: unknown): FreshAgentThreadSnapshot { + const parsed = FreshAgentThreadSnapshotSchema.safeParse(value) + if (!parsed.success) throw new FreshAgentContractValidationError('snapshot', parsed.error.issues) + return parsed.data +} +``` + +Use `FreshAgentSessionLocator` for `attach`, `subscribe`, `send`, `interrupt`, `kill`, `fork`, `respondToServerRequest`, and `startReview` routing. Internally store sessions by `makeFreshAgentSessionKey(locator)`, and when an action arrives for a bare `sessionId` from older compatibility paths, resolve it only if exactly one tracked session has that id; otherwise throw `FreshAgentSessionLocatorMismatchError` with a clear error requiring `sessionType` and `provider`. Parse snapshot, page, body, fork/action responses before returning them. + +In `router.ts`, map `FreshAgentContractValidationError` to HTTP 502: + +```ts +return res.status(502).json({ + error: error.message, + code: error.code, + details: error.issues, +}) +``` + +In `src/lib/api.ts`, parse fresh-agent helpers with the schemas and throw `FreshAgentApiPayloadError` from `src/lib/fresh-agent-api-error.ts` when parsing fails. Update helper signatures and REST paths to carry session identity explicitly and to use the canonical locator route `/api/fresh-agent/threads/:sessionType/:provider/:threadId`: + +```ts +getFreshAgentThreadSnapshot(sessionType, provider, threadId, options): Promise<FreshAgentThreadSnapshot> +getFreshAgentTurnPage(sessionType, provider, threadId, query): Promise<FreshAgentTurnPage> +getFreshAgentTurnBody(sessionType, provider, threadId, turnId, revision): Promise<FreshAgentTurnBody> +``` + +The router must accept `sessionType` in the request path, validate it with `FreshAgentSessionTypeSchema`, and pass it to the runtime manager. Do not reconstruct `sessionType` from `provider`, and do not leave a provider-only fresh-agent thread route active except as an explicit temporary backwards-compatibility redirect that rejects ambiguous shared-provider cases and is removed before final verification. +The fresh-agent turn-page REST query should use `FreshAgentTurnPageQuerySchema` from `shared/fresh-agent-contract.ts`, not the legacy `AgentTimelinePageQuerySchema`, because Freshcodex needs `sortDirection` for newest-first pages and must not expose `includeBodies` as a Codex app-server parameter. The router may keep an `includeBodies` compatibility branch only for non-Codex providers that still need it, but Freshcodex requests should use `sortDirection` plus bounded `limit`, and the Codex adapter must not forward Freshell-only `revision`, `priority`, or `includeBodies` fields to `thread/turns/list`. + +Replace `src/store/freshAgentSlice.ts`, `src/store/freshAgentTypes.ts`, and `src/store/freshAgentThunks.ts` with independent fresh-agent reducer, contract-shaped types, and thunk type prefixes. Store sessions by `FreshAgentSessionKey`, not by bare `sessionId`; each session value should still retain `sessionId`, `sessionType`, and `provider` for rendering and debugging. Keep action names fresh-agent-specific, for example `freshAgentCreateRegistered`, `freshAgentCreateFailed`, `freshAgentSnapshotReceived`, `freshAgentEventReceived`, and `freshAgentSessionLost`. `src/lib/fresh-agent-ws.ts` should dispatch these actions directly and should not import `agentChatSlice` actions. + +Update `src/lib/pane-activity.ts` so `agent-chat` panes continue to use `agentChatSessions`, while `fresh-agent` panes use the new fresh-agent session state by computing `makeFreshAgentSessionKey({ sessionType, provider, sessionId })` from pane content. `resolvePaneActivity`, `getBusyPaneIdsForTab`, and `collectBusySessionKeys` should accept `freshAgentSessions` separately from `agentChatSessions`; do not keep the current behavior where a Freshcodex pane has to appear in `agentChat.sessions` before activity, busy badges, or session keys work, and do not look up Freshcodex activity by bare `sessionId`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/fresh-agent/contract-boundary.test.ts \ + test/unit/server/fresh-agent/provider-registry.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/client/lib/api.fresh-agent-contract.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/server/fresh-agent/claude-normalize.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Remove duplicate local `FreshAgentSnapshot` types from client code only after Task 6 has shell/controller types in place. For now, ensure `api.ts` returns the exported contract types: + +```ts +export async function getFreshAgentThreadSnapshot(...): Promise<FreshAgentThreadSnapshot> +export async function getFreshAgentTurnPage(...): Promise<FreshAgentTurnPage> +export async function getFreshAgentTurnBody(...): Promise<FreshAgentTurnBody> +``` + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/fresh-agent/contract-boundary.test.ts \ + test/unit/server/fresh-agent/provider-registry.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/client/lib/api.fresh-agent-contract.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/server/fresh-agent/claude-normalize.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + server/index.ts \ + server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts \ + server/fresh-agent/runtime-manager.ts \ + server/fresh-agent/router.ts server/fresh-agent/adapters/claude/normalize.ts \ + server/fresh-agent/adapters/codex/normalize.ts src/lib/api.ts \ + src/lib/fresh-agent-ws.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts \ + src/store/freshAgentTypes.ts src/lib/pane-activity.ts \ + src/lib/fresh-agent-api-error.ts \ + test/unit/server/fresh-agent/contract-boundary.test.ts \ + test/unit/server/fresh-agent/provider-registry.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/client/lib/api.fresh-agent-contract.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/server/fresh-agent/claude-normalize.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts +git commit -m "Validate fresh-agent payloads at runtime boundaries" +``` + +### Task 4: Bring Codex App-Server Protocol Support Up To Freshcodex Needs + +**Files:** +- Modify: `server/index.ts` +- Modify: `server/coding-cli/codex-app-server/protocol.ts` +- Modify: `server/coding-cli/codex-app-server/client.ts` +- Modify: `server/coding-cli/codex-app-server/runtime.ts` +- Modify: `server/coding-cli/codex-app-server/launch-planner.ts` +- Create: `server/coding-cli/codex-app-server/transport.ts` +- Create: `server/coding-cli/codex-app-server/rich-runtime.ts` +- Modify: `server/fresh-agent/adapters/codex/adapter.ts` +- Modify: `package.json` +- Create: `scripts/audit-codex-app-server-schema.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ClientRequest.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerRequest.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerNotification.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/RequestId.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/RequestId.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCRequest.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCError.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCNotification.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCMessage.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalParams.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalParams.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadReadResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadStartResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ModelListResponse.json` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReasoningEffort.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InputModality.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/SubAgentSource.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReviewDecision.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionRequestApprovalResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/FileChangeRequestApprovalResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PermissionsRequestApprovalResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ToolRequestUserInputResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpServerElicitationRequestResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ChatgptAuthTokensRefreshResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/AskForApproval.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxMode.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxPolicy.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/NetworkAccess.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Thread.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Turn.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnError.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadItem.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/UserInput.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TextElement.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ByteRange.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/HookPromptFragment.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/MessagePhase.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitation.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitationEntry.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandAction.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionSource.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallResult.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallError.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallOutputContentItem.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/WebSearchAction.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadActiveFlag.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchApplyStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchChangeKind.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CollabAgentToolCallStatus.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SessionSource.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartSource.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSourceKind.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSortKey.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SortDirection.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Model.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReasoningEffortOption.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadParams.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadResponse.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts` +- Create: `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` +- Modify: `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs` +- Test: `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/transport.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts` +- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` + +- [ ] **Step 1: Generate local app-server schema and write failing protocol tests** + +Run this inspection command before editing code, then copy the listed generated files into `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/`: + +```bash +rm -rf /tmp/freshell-codex-app-server-schema +codex app-server generate-json-schema --out /tmp/freshell-codex-app-server-schema +codex app-server generate-ts --out /tmp/freshell-codex-app-server-schema-ts +find /tmp/freshell-codex-app-server-schema -maxdepth 3 -type f | sort | rg 'JSONRPC|Initialize|Thread|Turn|Approval|Request|Item|Fork|Interrupt|ServerRequest|Model|Capabilities' +``` + +Use the generated schema to verify exact parameter and response names for `initialize`, `initialized`, `thread/start`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `thread/fork`, `model/list`, `modelProvider/capabilities/read`, server notifications, approval server requests, and user-input server requests. The current local schema uses `thread/read { includeTurns: boolean }`, `thread/turns/list { cursor?, limit?, sortDirection? }`, `thread/turns/list -> { data, nextCursor?, backwardsCursor? }`, `model/list { cursor?, limit?, includeHidden? }`, `model/list -> { data, nextCursor? }`, `turn/start -> { turn }`, `turn/interrupt { threadId, turnId }`, `thread/fork -> { thread, ...metadata }`, and has no `thread/turn/read`; tests must encode those facts so a future implementation does not accidentally keep the stale API. Tests must also prove `thread/start` and `thread/resume` do not send stale fields such as `richClient`, `experimentalRawEvents`, or `persistExtendedHistory`. + +Add generated inventory assertions for both methods and field-level requiredness. Tests must parse method names and important required fields from the checked-in generated schema snapshot through `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts`, not from `/tmp`, so normal test runs and CI do not depend on an external `codex` executable. The generated `*.ts` snapshot files intentionally import many sibling type files that this reduced fixture does not check in, so `schema-inventory.ts` must read them as raw UTF-8 text with `fs`/`import.meta.url` path resolution and extract discriminant strings and required object fields. For wire-only constraints that TypeScript cannot express, such as `RequestId`'s integer numeric branch, `schema-inventory.ts` must also read the checked-in generated JSON Schema files. Do not import generated snapshot modules into the test module graph unless the entire generated dependency tree is checked in. The developer audit script may call the local `codex` executable and compare against the checked-in snapshot, but unit tests must be deterministic. + +Create `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` and `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts` in the same red step. This test must use `schema-inventory.ts` as its input and fail until every generated Codex surface is classified. The matrix entries should be data, not prose comments, so tests can assert completeness and cross-field consistency: + +```ts +type GeneratedCodexTraceabilityEntry = { + generatedKind: 'clientRequest' | 'serverRequest' | 'serverNotification' | 'threadItem' | 'runtimeLeaf' | 'entity' | 'response' + generatedName: string + generatedSource: string + support: 'implemented' | 'unsupported' | 'nonVisible' | 'runtimeGlobal' | 'connectionScoped' + protocolParser: string + outboundStrict?: boolean + normalizer?: string + freshAgentSchema?: string + runtimeOwner?: string + apiOrActionOwner?: string + uiOwner?: string + route?: { kind: 'thread' | 'runtimeGlobal' | 'connectionScoped' | 'nonVisible'; locator: string | null } + requiredFieldsCoveredBy?: string[] + optionalDefaultsCoveredBy?: string[] + fixtureOwners: string[] + testOwners: string[] + intentionalOmission?: { reason: string; behavior: string; test: string } +} +``` + +The failing tests must check: + +```ts +expect(unclassifiedGeneratedClientRequests()).toEqual([]) +expect(unclassifiedGeneratedServerRequests()).toEqual([]) +expect(unclassifiedGeneratedServerNotifications()).toEqual([]) +expect(unclassifiedGeneratedThreadItemVariants()).toEqual([]) +expect(unclassifiedGeneratedRuntimeLeaves()).toEqual([]) +expect(implementedEntriesMissingOwners()).toEqual([]) +expect(strictOutboundEntriesWithPassthrough()).toEqual([]) +expect(unsupportedEntriesMissingUserVisibleBehavior()).toEqual([]) +expect(notificationEntriesWithImplicitSubscriberRouting()).toEqual([]) +expect(serverRequestEntriesMissingGeneratedResponseSchema()).toEqual([]) +expect(itemEntriesMissingFreshAgentSchemaOrLosslessExtension()).toEqual([]) +expect(codexEntriesReferencingUnknownFreshAgentSchemas()).toEqual([]) +``` + +This is the durable replacement for manual issue-by-issue discovery. Each later test in Task 4 and Task 5 can still assert concrete fixtures, but those fixtures must be derived from traceability entries. A new Codex schema release that adds one generated method, request, notification, item variant, enum value, response shape, or required/defaulted field must make the traceability gate red before it can silently reach the adapter or UI. + +Field inventory tests must fail if `protocol.ts` accepts a generated-required entity with missing required fields or rejects generated-defaulted fields that may be omitted on the JSON wire. Add `schema-inventory.ts` helpers for both required fields and defaulted JSON-schema properties, for example `requiredFieldsForGeneratedJsonSchema(...)` and `defaultedFieldsForGeneratedJsonSchema(...)`. At minimum, assert these local schema facts: + +```ts +expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadReadResponse.json', 'Thread')).toEqual(expect.arrayContaining([ + 'id', + 'preview', + 'ephemeral', + 'modelProvider', + 'createdAt', + 'updatedAt', + 'status', + 'cwd', + 'cliVersion', + 'source', + 'turns', +])) +expect(() => CodexThreadSchema.parse({ id: 'thread-missing-required-fields' })).toThrow(/turns|cwd|createdAt/i) +expect(CodexThreadSchema.parse({ + id: 'thread-optional-null-fields', + preview: '', + ephemeral: false, + modelProvider: 'openai', + createdAt: 1, + updatedAt: 1, + status: { type: 'idle' }, + cwd: '/repo', + cliVersion: '0.128.0', + source: 'vscode', + turns: [], +})).toMatchObject({ + forkedFromId: null, + path: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, +}) +expect(requestIdTypeFromGeneratedTs()).toEqual('string | number') +expect(requestIdNumericTypeFromGeneratedJsonSchema()).toEqual('integer') +expect(CodexRequestIdSchema.parse(42)).toBe(42) +expect(() => CodexRequestIdSchema.parse(42.5)).toThrow(/integer/i) +expect(CodexThreadTurnsListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null, backwardsCursor: null }) +expect(CodexThreadReadResultSchema.parse({ thread: schemaValidThread({ turns: [] }) }).thread.turns).toEqual([]) +expect(sourceKindValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'cli', + 'vscode', + 'exec', + 'appServer', + 'subAgent', + 'subAgentReview', + 'subAgentCompact', + 'subAgentThreadSpawn', + 'subAgentOther', +])) +expect(threadStartSourceValuesFromGeneratedSchema()).toEqual(['startup', 'clear']) +expect(CodexThreadListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null, backwardsCursor: null }) +expect(CodexModelListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null }) +expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'ThreadStartResponse')).toEqual(expect.arrayContaining([ + 'thread', + 'model', + 'modelProvider', + 'cwd', + 'approvalPolicy', + 'approvalsReviewer', + 'sandbox', +])) +expect(() => CodexThreadStartResultSchema.parse({ thread: schemaValidThread({ turns: [] }) })).toThrow(/model|cwd|approvalPolicy|sandbox/i) +expect(() => CodexThreadResumeResultSchema.parse({ thread: schemaValidThread({ turns: [] }) })).toThrow(/model|cwd|approvalPolicy|sandbox/i) +expect(() => CodexThreadForkResultSchema.parse({ thread: schemaValidThread({ turns: [] }), model: 'fixture', modelProvider: 'fixture', cwd: '/repo' })).toThrow(/approvalPolicy|sandbox/i) +expect(CodexThreadForkResultSchema.parse({ + thread: schemaValidThread({ turns: [] }), + model: 'fixture', + modelProvider: 'fixture-provider', + cwd: '/repo', + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { type: 'dangerFullAccess' }, +})).toMatchObject({ + serviceTier: null, + instructionSources: [], + reasoningEffort: null, +}) +expect(requiredFieldsForGeneratedJsonSchema('v2/ModelListResponse.json', 'Model')).toEqual(expect.arrayContaining([ + 'id', + 'model', + 'displayName', + 'description', + 'hidden', + 'supportedReasoningEfforts', + 'defaultReasoningEffort', + 'isDefault', +])) +expect(requiredFieldsForGeneratedType('v2/ReasoningEffortOption.ts', 'ReasoningEffortOption')).toEqual(['reasoningEffort', 'description']) +expect(() => CodexModelSchema.parse({ id: 'model-missing-required-fields' })).toThrow(/displayName|defaultReasoningEffort/i) +expect(CodexModelSchema.parse({ + id: 'model-defaulted-fields', + model: 'model-defaulted-fields', + displayName: 'Defaulted Fields', + description: '', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: 'medium', + isDefault: false, +})).toMatchObject({ + inputModalities: ['text', 'image'], + supportsPersonality: false, + additionalSpeedTiers: [], + upgrade: null, + upgradeInfo: null, + availabilityNux: null, +}) +expect(requiredFieldsForGeneratedType('v2/ModelProviderCapabilitiesReadResponse.ts', 'ModelProviderCapabilitiesReadResponse')).toEqual([ + 'namespaceTools', + 'imageGeneration', + 'webSearch', +]) +expect(CodexModelProviderCapabilitiesReadResultSchema.parse({ + namespaceTools: true, + imageGeneration: false, + webSearch: true, +})).toEqual({ namespaceTools: true, imageGeneration: false, webSearch: true }) +expect(requiredFieldsForGeneratedJsonSchema('ApplyPatchApprovalParams.json', 'ApplyPatchApprovalParams')).toEqual(expect.arrayContaining([ + 'conversationId', + 'callId', + 'fileChanges', +])) +expect(requiredFieldsForGeneratedJsonSchema('ApplyPatchApprovalParams.json', 'ApplyPatchApprovalParams')).not.toEqual(expect.arrayContaining([ + 'reason', + 'grantRoot', +])) +expect(CodexLegacyApplyPatchApprovalParamsSchema.parse({ + conversationId: 'thread-1', + callId: 'patch-1', + fileChanges: {}, +})).toMatchObject({ + reason: null, + grantRoot: null, +}) +expect(requiredFieldsForGeneratedJsonSchema('ExecCommandApprovalParams.json', 'ExecCommandApprovalParams')).toEqual(expect.arrayContaining([ + 'conversationId', + 'callId', + 'command', + 'cwd', + 'parsedCmd', +])) +expect(requiredFieldsForGeneratedJsonSchema('ExecCommandApprovalParams.json', 'ExecCommandApprovalParams')).not.toEqual(expect.arrayContaining([ + 'approvalId', + 'reason', +])) +expect(CodexLegacyExecCommandApprovalParamsSchema.parse({ + conversationId: 'thread-1', + callId: 'exec-1', + command: ['npm', 'test'], + cwd: '/repo', + parsedCmd: [], +})).toMatchObject({ + approvalId: null, + reason: null, +}) +expect(reviewDecisionValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'approved', + 'approved_for_session', + 'denied', + 'timed_out', + 'abort', + 'approved_execpolicy_amendment', + 'network_policy_amendment', +])) +expect(CodexLegacyApplyPatchApprovalResponseSchema.parse({ decision: 'approved' })).toEqual({ decision: 'approved' }) +expect(CodexLegacyExecCommandApprovalResponseSchema.parse({ decision: 'denied' })).toEqual({ decision: 'denied' }) +expect(reasoningEffortValuesFromGeneratedSchema()).toEqual(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) +expect(inputModalityValuesFromGeneratedSchema()).toEqual(['text', 'image']) +expect(askForApprovalValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'untrusted', + 'on-failure', + 'on-request', + 'never', + 'granular', +])) +expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'AskForApproval.granular')).toEqual(expect.objectContaining({ + skill_approval: false, + request_permissions: false, +})) +expect(CodexApprovalPolicySchema.parse({ + granular: { + sandbox_approval: true, + rules: true, + mcp_elicitations: false, + }, +})).toEqual({ + granular: { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }, +}) +expect(sandboxModeValuesFromGeneratedSchema()).toEqual(['read-only', 'workspace-write', 'danger-full-access']) +expect(sandboxPolicyVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'dangerFullAccess', + 'readOnly', + 'externalSandbox', + 'workspaceWrite', +])) +expect(networkAccessValuesFromGeneratedSchema()).toEqual(['restricted', 'enabled']) +expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.readOnly')).toEqual({ networkAccess: false }) +expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.externalSandbox')).toEqual({ networkAccess: 'restricted' }) +expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.workspaceWrite')).toEqual({ + writableRoots: [], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, +}) +expect(CodexSandboxPolicySchema.parse({ type: 'readOnly' })).toEqual({ type: 'readOnly', networkAccess: false }) +expect(CodexSandboxPolicySchema.parse({ type: 'externalSandbox' })).toEqual({ type: 'externalSandbox', networkAccess: 'restricted' }) +expect(CodexSandboxPolicySchema.parse({ type: 'workspaceWrite' })).toEqual({ + type: 'workspaceWrite', + writableRoots: [], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, +}) +expect(userInputVariantsFromGeneratedSchema()).toEqual(['text', 'image', 'localImage', 'skill', 'mention']) +expect(threadStatusVariantsFromGeneratedSchema()).toEqual(['notLoaded', 'idle', 'systemError', 'active']) +expect(() => CodexThreadStatusSchema.parse({ type: 'active' })).toThrow(/activeFlags/i) +expect(turnStatusValuesFromGeneratedSchema()).toEqual(['completed', 'interrupted', 'failed', 'inProgress']) +expect(commandExecutionStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed', 'declined']) +expect(patchApplyStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed', 'declined']) +expect(patchChangeKindVariantsFromGeneratedSchema()).toEqual(['add', 'delete', 'update']) +expect(patchChangeKindFieldsFromGeneratedSchema('update')).toEqual(expect.arrayContaining(['move_path'])) +expect(mcpToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed']) +expect(dynamicToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed']) +expect(collabAgentToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed']) +expect(imageGenerationStatusTypeFromGeneratedSchema()).toEqual('string') +expect(requiredFieldsForGeneratedType('v2/HookPromptFragment.ts', 'HookPromptFragment')).toEqual(expect.arrayContaining(['text', 'hookRunId'])) +expect(requiredFieldsForGeneratedType('v2/TextElement.ts', 'TextElement')).toEqual(expect.arrayContaining(['byteRange', 'placeholder'])) +expect(requiredFieldsForGeneratedType('v2/MemoryCitation.ts', 'MemoryCitation')).toEqual(expect.arrayContaining(['entries', 'threadIds'])) +expect(messagePhaseValuesFromGeneratedSchema()).toEqual(['commentary', 'final_answer']) +expect(commandActionVariantsFromGeneratedSchema()).toEqual(['read', 'listFiles', 'search', 'unknown']) +expect(commandExecutionSourceValuesFromGeneratedSchema()).toEqual(['agent', 'userShell', 'unifiedExecStartup', 'unifiedExecInteraction']) +expect(requiredFieldsForGeneratedType('v2/McpToolCallResult.ts', 'McpToolCallResult')).toEqual(expect.arrayContaining(['content', 'structuredContent', '_meta'])) +expect(dynamicToolCallOutputContentItemVariantsFromGeneratedSchema()).toEqual(['inputText', 'inputImage']) +expect(webSearchActionVariantsFromGeneratedSchema()).toEqual(['search', 'openPage', 'findInPage', 'other']) +expect(threadItemVariantFieldsFromGeneratedSchema('imageGeneration')).toEqual(expect.arrayContaining(['status', 'revisedPrompt', 'result', 'savedPath'])) +expect(threadItemFieldTypeFromGeneratedSchema('mcpToolCall', 'arguments')).toEqual('JsonValue') +expect(threadItemFieldTypeFromGeneratedSchema('dynamicToolCall', 'arguments')).toEqual('JsonValue') +expect(serverRequestFieldTypeFromGeneratedSchema('item/tool/call', 'arguments')).toEqual('JsonValue') +expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadReadResponse.json', 'Turn')).toEqual(expect.arrayContaining([ + 'id', + 'items', + 'status', +])) +expect(CodexTurnSchema.parse({ id: 'turn-missing-optional-nullables', items: [], status: 'completed' })).toMatchObject({ + error: null, + startedAt: null, + completedAt: null, + durationMs: null, +}) +expect(sessionSourceVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'cli', + 'vscode', + 'exec', + 'appServer', + 'custom', + 'subAgent', + 'unknown', +])) +expect(subAgentSourceVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([ + 'review', + 'compact', + 'thread_spawn', + 'memory_consolidation', + 'other', +])) +expect(CodexThreadSchema.parse(schemaValidThread({ + source: { subAgent: { thread_spawn: { + parent_thread_id: 'thread-parent-1', + depth: 1, + agent_path: null, + agent_nickname: 'reviewer', + agent_role: 'review', + } } }, + turns: [], +}))).toMatchObject({ + source: { subAgent: { thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }) } }, +}) +``` + +This is required because `thread/read { includeTurns: false }` returns a schema-valid `Thread` with `turns: []`, not a partial object with `turns` omitted. Do not loosen `protocol.ts` to make impossible mocks easier to write. +It is also required because `ThreadSourceKind` and `SessionSource` are different generated types: `sourceKinds` filters use flattened subagent source-kind strings, while the `Thread.source` metadata returned in `Thread` objects preserves nested subagent details. The checked-in schema snapshot and inventory tests must cover both so Freshcodex history filters and child-thread metadata do not accidentally share one lossy source enum. +Add fixture helpers such as `schemaValidThread`, `schemaValidTurn`, `schemaValidCodexItem`, `schemaValidThreadLifecycleResult`, and `schemaValidModel`; adapter/runtime tests must use those helpers instead of `{ thread: { id } }`, `{ turn: { id } }`, `{ item: { id } }`, `{ model: { id } }`, or partial lifecycle/model responses. + +Add a package script so the schema audit is runnable from normal verification commands: + +```json +{ + "scripts": { + "audit:codex-app-server-schema": "tsx scripts/audit-codex-app-server-schema.ts" + } +} +``` + +Compare generated method names to two explicit sets: + +- implemented in Freshcodex rich runtime: `initialize`, `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `review/start`, `model/list`, `modelProvider/capabilities/read` +- explicitly unsupported in Freshcodex rich runtime: every other generated method + +The traceability matrix must own these sets. The test must fail if a new generated client method appears in the checked-in schema snapshot without being classified, and must fail if a method outside the implemented set is accidentally proxied through as a generic request. It must also fail if an implemented client request parser accepts a field that is not in the generated parameter type. Add negative assertions that `CodexThreadStartParamsSchema`, `CodexThreadResumeParamsSchema`, `CodexThreadForkParamsSchema`, and `CodexTurnStartParamsSchema` reject stale fields such as `persistExtendedHistory`, `richClient`, `experimentalRawEvents`, `revision`, `includeBodies`, and thread-level `sandbox` on `turn/start`. `scripts/audit-codex-app-server-schema.ts` must fail when the local generated schema differs from the checked-in snapshot and print the new method/type names or required-field changes that require updating fixtures and classification. + +Add the same generated-inventory coverage for `ServerNotification` routing. Every method in the checked-in `ServerNotification.ts` snapshot must be classified as exactly one of `thread`, `runtimeGlobal`, `connectionScoped`, or `nonVisible`. The classification test must prove `thread/started` extracts `params.thread.id`, ordinary thread events extract `params.threadId`, `warning` branches on nullable `params.threadId`, and no-locator `command/exec/outputDelta` / `fs/changed` do not produce a Freshcodex thread invalidation until a future feature records a process/watch owner. + +Add transport tests requiring stdio JSONL framing and websocket preservation: + +```ts +const transport = new CodexStdioJsonlTransport(fakeChildProcess) +await transport.send({ id: 1, method: 'initialize', params: initializeParams }) +expect(fakeChild.stdinLines).toEqual([ + JSON.stringify({ id: 1, method: 'initialize', params: initializeParams }), +]) +fakeChild.stdout.push(JSON.stringify({ id: 1, result: initializeResponse }) + '\n') +expect(await transport.nextMessage()).toEqual({ id: 1, result: initializeResponse }) + +const wsTransport = new CodexWebSocketTransport({ wsUrl: 'ws://127.0.0.1:43123' }) +await wsTransport.send({ id: 2, method: 'thread/start', params: threadStartParams }) +expect(fakeWebSocket.sentMessages).toContainEqual(JSON.stringify({ id: 2, method: 'thread/start', params: threadStartParams })) +``` + +Then add client/runtime tests requiring: + +```ts +await expect(client.initialize()).resolves.toMatchObject({ + userAgent: expect.any(String), + codexHome: expect.any(String), + platformFamily: expect.any(String), + platformOs: expect.any(String), +}) +const initializeRequest = fakeTransport.sent.find((message) => message.method === 'initialize') +expect(initializeRequest).toMatchObject({ + params: { + capabilities: expect.objectContaining({ + experimentalApi: false, + }), + }, +}) +expect(initializeRequest.params.capabilities.optOutNotificationMethods ?? []) + .not.toEqual(expect.arrayContaining([ + 'thread/started', + 'turn/started', + 'turn/completed', + 'item/started', + 'item/completed', + 'thread/tokenUsage/updated', + 'turn/diff/updated', + 'error', + ])) +expect(fakeTransport.sent).toContainEqual({ method: 'initialized' }) + +await expect(client.readThread({ threadId: 'thread-1', includeTurns: true })) + .resolves.toMatchObject({ thread: { id: 'thread-1' } }) + +await expect(client.resumeThread({ threadId: 'thread-1', excludeTurns: true })) + .resolves.toMatchObject({ thread: { id: 'thread-1', turns: [] } }) +expect(fakeTransport.sent).toContainEqual(expect.objectContaining({ + method: 'thread/resume', + params: expect.objectContaining({ + threadId: 'thread-1', + excludeTurns: true, + }), +})) +expect(fakeTransport.sent.find((message) => message.method === 'thread/resume')?.params) + .not.toHaveProperty('persistExtendedHistory') + +await expect(client.listThreadTurns({ threadId: 'thread-1', limit: 25, sortDirection: 'desc' })) + .resolves.toMatchObject({ data: expect.any(Array), nextCursor: null }) + +await expect(client.startTurn({ + threadId: 'thread-1', + input: [{ type: 'text', text: 'Implement this', text_elements: [] }], +})).resolves.toMatchObject({ turn: { id: expect.any(String) } }) + +await expect(client.interruptTurn({ threadId: 'thread-1', turnId: 'turn-1' })).resolves.toEqual({}) + +await expect(client.forkThread({ threadId: 'thread-1', excludeTurns: true })) + .resolves.toMatchObject({ thread: { id: expect.any(String) } }) + +await expect(client.listThreads({ limit: 25 })) + .resolves.toMatchObject({ data: expect.any(Array) }) + +await expect(client.listLoadedThreads({})) + .resolves.toMatchObject({ data: ['thread-1'], nextCursor: null }) + +await expect(client.startReview({ threadId: 'thread-1', target: { type: 'uncommittedChanges' }, delivery: 'inline' })) + .resolves.toMatchObject({ turn: expect.any(Object), reviewThreadId: 'thread-1' }) + +await expect(client.listModels({ limit: 25 })) + .resolves.toMatchObject({ data: expect.any(Array), nextCursor: null }) + +await expect(client.readModelProviderCapabilities({})) + .resolves.toEqual({ + namespaceTools: expect.any(Boolean), + imageGeneration: expect.any(Boolean), + webSearch: expect.any(Boolean), + }) + +await expect(runtime.startTurn({ threadId: 'thread-1', input: [{ type: 'text', text: 'Hello', text_elements: [] }] })) + .resolves.toMatchObject({ turn: { id: expect.any(String) } }) + +expect('readThreadTurn' in client).toBe(false) // no public method; direct turn read is not in the generated schema +expect('readThreadTurn' in runtime).toBe(false) // the raw websocket runtime must not keep a fake turn-read API either + +await expect(websocketRuntime.startThread({ cwd: '/repo' })) + .resolves.toMatchObject({ threadId: expect.any(String), wsUrl: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/) }) +await expect(richRuntime.startThread({ cwd: '/repo' })) + .resolves.toMatchObject({ threadId: expect.any(String) }) +expect(await richRuntime.ensureReady()).not.toHaveProperty('wsUrl') +``` + +Add server-request tests in `client.test.ts`: + +```ts +it('surfaces server-initiated approval requests and responds on the same JSON-RPC id', async () => { + const seen: unknown[] = [] + client.onServerRequest((request) => seen.push(request)) + await fakeServer.sendRequest({ id: 'approval-99', method: 'item/commandExecution/requestApproval', params: approvalParams }) + await client.respondToServerRequest('approval-99', { decision: 'accept' }) + expect(fakeServer.responses).toContainEqual({ id: 'approval-99', result: { decision: 'accept' } }) +}) + +it('surfaces runtime-global server requests without inventing a thread id', async () => { + const seen: unknown[] = [] + client.onServerRequest((request) => seen.push(request)) + await fakeServer.sendRequest({ + id: 'auth-refresh-1', + method: 'account/chatgptAuthTokens/refresh', + params: { reason: 'unauthorized', previousAccountId: null }, + }) + await client.respondToServerRequestError('auth-refresh-1', { + code: -32050, + message: 'Freshell cannot refresh Codex ChatGPT auth tokens from this runtime.', + }) + expect(seen).toContainEqual(expect.objectContaining({ id: 'auth-refresh-1', method: 'account/chatgptAuthTokens/refresh' })) + expect(fakeServer.responses).toContainEqual({ + id: 'auth-refresh-1', + error: expect.objectContaining({ code: -32050 }), + }) +}) +``` + +Add notification forwarding tests in `client.test.ts` and `rich-runtime.test.ts`: + +```ts +it('forwards app-server notifications without treating them as request responses', async () => { + const notifications: unknown[] = [] + client.onNotification((notification) => notifications.push(notification)) + await fakeServer.sendNotification({ method: 'turn/started', params: { threadId: 'thread-1', turn: schemaValidTurn({ id: 'turn-1' }) } }) + expect(notifications).toContainEqual({ method: 'turn/started', params: expect.objectContaining({ threadId: 'thread-1' }) }) + expect(client.pendingRequestCountForTest()).toBe(0) +}) + +it('classifies notification thread locators from generated params instead of subscriber state', async () => { + await expect(notificationRouteFor(CodexServerNotificationSchema.parse({ + method: 'thread/started', + params: { thread: schemaValidThread({ id: 'thread-from-nested-thread' }) }, + }))).toEqual({ kind: 'thread', threadId: 'thread-from-nested-thread' }) + await expect(notificationRouteFor(CodexServerNotificationSchema.parse({ + method: 'warning', + params: { threadId: null, message: 'Global warning' }, + }))).toEqual({ kind: 'runtimeGlobal' }) + await expect(notificationRouteFor(CodexServerNotificationSchema.parse({ + method: 'command/exec/outputDelta', + params: { processId: 'process-1', stream: 'stdout', deltaBase64: '', capReached: false }, + }))).toEqual({ kind: 'connectionScoped', owner: 'unsupported-command-exec' }) +}) + +it('lets the rich stdio runtime subscribe to notifications and server requests for a specific Freshcodex session', async () => { + const seen: unknown[] = [] + const unsubscribe = await richRuntime.subscribe('thread-1', (event) => seen.push(event)) + await fakeServer.sendNotification({ method: 'item/completed', params: { threadId: 'thread-1', turnId: 'turn-1', item: schemaValidCodexItem({ type: 'plan', id: 'item-1', text: 'Plan' }) } }) + expect(seen).toContainEqual(expect.objectContaining({ method: 'item/completed' })) + unsubscribe() +}) + +it('does not deliver no-locator global or connection-scoped notifications as thread invalidations', async () => { + const seen: unknown[] = [] + const unsubscribe = await richRuntime.subscribe('thread-1', (event) => seen.push(event)) + await fakeServer.sendNotification({ method: 'thread/started', params: { thread: schemaValidThread({ id: 'thread-2' }) } }) + await fakeServer.sendNotification({ method: 'fs/changed', params: { watchId: 'watch-1', changedPaths: ['/repo/file.ts'] } }) + await fakeServer.sendNotification({ method: 'warning', params: { threadId: null, message: 'Global warning' } }) + expect(seen).not.toContainEqual(expect.objectContaining({ threadId: 'thread-1', reason: 'thread/started' })) + expect(seen).not.toContainEqual(expect.objectContaining({ threadId: 'thread-1', reason: 'fs/changed' })) + expect(seen).toContainEqual(expect.objectContaining({ method: 'warning', route: { kind: 'runtimeGlobal' } })) + unsubscribe() +}) + +it('shuts down the rich stdio app-server child without touching the raw websocket runtime', async () => { + await richRuntime.ensureReady() + await richRuntime.shutdown() + expect(fakeStdioChild.killed).toBe(true) + expect(websocketRuntime.status()).toBe('running') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/coding-cli/codex-app-server/transport.test.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts +``` + +Expected: FAIL because the client still owns WebSocket directly, emits `"jsonrpc": "2.0"`, does not send `initialized`, parses the old initialize result, exposes stale turn-read behavior, lacks turn, fork, interrupt, and server-request response methods, and has no Freshcodex-only stdio rich runtime. + +- [ ] **Step 3: Implement app-server protocol methods** + +Implement `schema-traceability.ts` before filling in broad protocol/client behavior. Keep it data-only and importable by tests without starting Codex. The entries should reference the exact parser/export names that this step adds to `protocol.ts`, the rich-runtime/client method names that own supported requests, and the UI/API owners that later tasks must satisfy. During this step it is acceptable for Task 5 UI owners to point at planned owners such as `FreshAgentItemCard` or `FreshAgentWorkspacePanel`, but they must be concrete file/component names and later tasks must update or satisfy them. Do not leave placeholder owners such as `TODO`, `unknown`, or `adapter`. + +Update `protocol.ts` with schema names matching the generated app-server schema. The implementation must include generated response schemas for every server request that Freshell answers, not only request-param schemas. The checked-in schema snapshot and `schema-inventory.ts` should cover `CommandExecutionRequestApprovalResponse`, `FileChangeRequestApprovalResponse`, `PermissionsRequestApprovalResponse`, `ToolRequestUserInputResponse`, `McpServerElicitationRequestResponse`, `DynamicToolCallResponse`, `ChatgptAuthTokensRefreshResponse`, root `ApplyPatchApprovalResponse`, root `ExecCommandApprovalResponse`, and root `ReviewDecision` so tests fail when Codex changes the payload shape Freshell sends back to unblock a turn. + +The implementation must include, at minimum: + +```ts +export const CodexRequestIdSchema = z.union([z.string().min(1), z.number().int()]) + +export const CodexInitializeResultSchema = z.object({ + userAgent: z.string().min(1), + codexHome: z.string().min(1), + platformFamily: z.string().min(1), + platformOs: z.string().min(1), +}) + +export const CodexThreadReadParamsSchema = z.object({ + threadId: z.string().min(1), + includeTurns: z.boolean(), +}).strict() + +export const CodexThreadTurnsListParamsSchema = z.object({ + threadId: z.string().min(1), + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), +}).strict() + +export const CodexThreadTurnsListResultSchema = z.object({ + data: z.array(z.lazy(() => CodexTurnSchema)), + nextCursor: z.string().nullable().optional().default(null), + backwardsCursor: z.string().nullable().optional().default(null), +}) + +export const CodexThreadReadResultSchema = z.object({ + thread: z.lazy(() => CodexThreadSchema), +}) + +export const CodexThreadSourceKindSchema = z.enum([ + 'cli', + 'vscode', + 'exec', + 'appServer', + 'subAgent', + 'subAgentReview', + 'subAgentCompact', + 'subAgentThreadSpawn', + 'subAgentOther', + 'unknown', +]) +export const CodexSubAgentSourceSchema = z.union([ + z.literal('review'), + z.literal('compact'), + z.object({ + thread_spawn: z.object({ + parent_thread_id: z.string().min(1), + depth: z.number().int().nonnegative(), + agent_path: z.unknown().nullable(), + agent_nickname: z.string().nullable(), + agent_role: z.string().nullable(), + }), + }), + z.literal('memory_consolidation'), + z.object({ other: z.string() }), +]) +export const CodexSessionSourceSchema = z.union([ + z.literal('cli'), + z.literal('vscode'), + z.literal('exec'), + z.literal('appServer'), + z.object({ custom: z.string() }), + z.object({ subAgent: CodexSubAgentSourceSchema }), + z.literal('unknown'), +]) +export const CodexThreadActiveFlagSchema = z.enum(['waitingOnApproval', 'waitingOnUserInput']) +export const CodexThreadStatusSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('notLoaded') }), + z.object({ type: z.literal('idle') }), + z.object({ type: z.literal('systemError') }), + z.object({ type: z.literal('active'), activeFlags: z.array(CodexThreadActiveFlagSchema) }), +]) +export const CodexThreadSortKeySchema = z.enum(['created_at', 'updated_at']) + +export const CodexThreadListParamsSchema = z.object({ + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().optional(), + sortKey: CodexThreadSortKeySchema.nullable().optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), + modelProviders: z.array(z.string()).nullable().optional(), + sourceKinds: z.array(CodexThreadSourceKindSchema).nullable().optional(), + archived: z.boolean().nullable().optional(), + cwd: z.union([z.string(), z.array(z.string())]).nullable().optional(), + useStateDbOnly: z.boolean().optional(), + searchTerm: z.string().nullable().optional(), +}).strict() + +export const CodexThreadLoadedListParamsSchema = z.object({ + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().optional(), +}).strict() + +export const CodexThreadListResultSchema = z.object({ + data: z.array(z.lazy(() => CodexThreadSchema)), + nextCursor: z.string().nullable().optional().default(null), + backwardsCursor: z.string().nullable().optional().default(null), +}) + +export const CodexThreadLoadedListResultSchema = z.object({ + data: z.array(z.string().min(1)), + nextCursor: z.string().nullable().optional().default(null), +}) + +export const CodexModelProviderCapabilitiesReadResultSchema = z.object({ + namespaceTools: z.boolean(), + imageGeneration: z.boolean(), + webSearch: z.boolean(), +}) + +export const CodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) + +export const CodexModelSchema = z.object({ + id: z.string().min(1), + model: z.string().min(1), + upgrade: z.string().nullable().optional().default(null), + upgradeInfo: z.unknown().nullable().optional().default(null), + availabilityNux: z.unknown().nullable().optional().default(null), + displayName: z.string(), + description: z.string(), + hidden: z.boolean(), + supportedReasoningEfforts: z.array(z.object({ + reasoningEffort: CodexReasoningEffortSchema, + description: z.string(), + })), + defaultReasoningEffort: CodexReasoningEffortSchema, + inputModalities: z.array(z.enum(['text', 'image'])).optional().default(['text', 'image']), + supportsPersonality: z.boolean().optional().default(false), + additionalSpeedTiers: z.array(z.string()).optional().default([]), + isDefault: z.boolean(), +}) + +export const CodexModelListResultSchema = z.object({ + data: z.array(CodexModelSchema), + nextCursor: z.string().nullable().optional().default(null), +}) + +export const CodexModelListParamsSchema = z.object({ + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().optional(), + includeHidden: z.boolean().nullable().optional(), +}).strict() + +export const CodexModelProviderCapabilitiesReadParamsSchema = z.object({}).strict() + +export const CodexTurnInputItemSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string(), text_elements: z.array(z.unknown()).default([]) }), + z.object({ type: z.literal('image'), url: z.string().url() }), + z.object({ type: z.literal('localImage'), path: z.string().min(1) }), + z.object({ type: z.literal('skill'), name: z.string().min(1), path: z.string().min(1) }), + z.object({ type: z.literal('mention'), name: z.string().min(1), path: z.string().min(1) }), +]) + +export const CodexApprovalPolicySchema = z.union([ + z.enum(['untrusted', 'on-failure', 'on-request', 'never']), + z.object({ + granular: z.object({ + sandbox_approval: z.boolean(), + rules: z.boolean(), + skill_approval: z.boolean().optional().default(false), + request_permissions: z.boolean().optional().default(false), + mcp_elicitations: z.boolean(), + }), + }), +]) +export const CodexSandboxPolicySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('dangerFullAccess') }), + z.object({ type: z.literal('readOnly'), networkAccess: z.boolean().optional().default(false) }), + z.object({ type: z.literal('externalSandbox'), networkAccess: z.enum(['restricted', 'enabled']).optional().default('restricted') }), + z.object({ + type: z.literal('workspaceWrite'), + writableRoots: z.array(z.string()).optional().default([]), + networkAccess: z.boolean().optional().default(false), + excludeTmpdirEnvVar: z.boolean().optional().default(false), + excludeSlashTmp: z.boolean().optional().default(false), + }), +]) + +export const CodexServiceTierSchema = z.enum(['fast', 'flex']) +export const CodexApprovalsReviewerSchema = z.enum(['user', 'auto_review', 'guardian_subagent']) +export const CodexThreadSandboxModeSchema = z.enum(['read-only', 'workspace-write', 'danger-full-access']) +export const CodexReasoningSummarySchema = z.enum(['auto', 'concise', 'detailed', 'none']) +export const CodexPersonalitySchema = z.enum(['none', 'friendly', 'pragmatic']) +export const CodexThreadStartSourceSchema = z.enum(['startup', 'clear']) +export const CodexRuntimeConfigSchema = z.record(z.string(), JsonValue) + +const CodexThreadRuntimeOverridesSchema = z.object({ + model: z.string().nullable().optional(), + modelProvider: z.string().nullable().optional(), + serviceTier: CodexServiceTierSchema.nullable().optional(), + cwd: z.string().nullable().optional(), + approvalPolicy: CodexApprovalPolicySchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + sandbox: CodexThreadSandboxModeSchema.nullable().optional(), + config: CodexRuntimeConfigSchema.nullable().optional(), + baseInstructions: z.string().nullable().optional(), + developerInstructions: z.string().nullable().optional(), +}) + +export const CodexThreadStartParamsSchema = CodexThreadRuntimeOverridesSchema.extend({ + serviceName: z.string().nullable().optional(), + personality: CodexPersonalitySchema.nullable().optional(), + ephemeral: z.boolean().nullable().optional(), + sessionStartSource: CodexThreadStartSourceSchema.nullable().optional(), +}).strict() + +export const CodexThreadResumeParamsSchema = CodexThreadRuntimeOverridesSchema.extend({ + threadId: z.string().min(1), + personality: CodexPersonalitySchema.nullable().optional(), + excludeTurns: z.boolean().optional(), +}).strict() + +export const CodexTurnStartParamsSchema = z.object({ + threadId: z.string().min(1), + input: z.array(CodexTurnInputItemSchema).min(1), + cwd: z.string().nullable().optional(), + approvalPolicy: CodexApprovalPolicySchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + sandboxPolicy: CodexSandboxPolicySchema.nullable().optional(), + model: z.string().nullable().optional(), + serviceTier: CodexServiceTierSchema.nullable().optional(), + effort: CodexReasoningEffortSchema.nullable().optional(), + summary: CodexReasoningSummarySchema.nullable().optional(), + personality: CodexPersonalitySchema.nullable().optional(), + outputSchema: JsonValue.nullable().optional(), +}).strict() + +export const CodexThreadForkParamsSchema = CodexThreadRuntimeOverridesSchema.extend({ + threadId: z.string().min(1), + ephemeral: z.boolean().optional(), + excludeTurns: z.boolean().optional(), +}).strict() + +export const CodexReviewTargetSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('uncommittedChanges') }), + z.object({ type: z.literal('baseBranch'), branch: z.string().min(1) }), + z.object({ type: z.literal('commit'), sha: z.string().min(1), title: z.string().nullable() }), + z.object({ type: z.literal('custom'), instructions: z.string().min(1) }), +]) + +export const CodexReviewStartParamsSchema = z.object({ + threadId: z.string().min(1), + target: CodexReviewTargetSchema, + delivery: z.enum(['inline', 'detached']).nullable().optional(), +}).strict() +``` + +Use generated schema field names. Do not guess against tests. Delete the stale `CodexThreadTurnRead*` schemas unless a future generated schema actually contains a direct turn-read client request. + +Model the response schemas with the generated shapes, not Freshell convenience shapes: + +```ts +const CodexThreadLifecycleResultSchema = z.object({ + thread: z.lazy(() => CodexThreadSchema), + model: z.string().min(1), + modelProvider: z.string().min(1), + serviceTier: CodexServiceTierSchema.nullable().optional().default(null), + cwd: z.string().min(1), + instructionSources: z.array(z.string()).optional().default([]), + approvalPolicy: CodexApprovalPolicySchema, + approvalsReviewer: CodexApprovalsReviewerSchema, + sandbox: CodexSandboxPolicySchema, + reasoningEffort: CodexReasoningEffortSchema.nullable().optional().default(null), +}) + +export const CodexThreadStartResultSchema = CodexThreadLifecycleResultSchema +export const CodexThreadResumeResultSchema = CodexThreadLifecycleResultSchema +export const CodexThreadForkResultSchema = CodexThreadLifecycleResultSchema + +export const CodexTurnStartResultSchema = z.object({ + turn: z.lazy(() => CodexTurnSchema), +}) + +export const CodexTurnInterruptParamsSchema = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), +}) + +export const CodexTurnInterruptResultSchema = z.object({}).passthrough() + +``` + +Also model generated app-server read shapes used by later normalization and UI tasks: + +```ts +export const CodexUserInputSchema = z.discriminatedUnion('type', [...]) +export const CodexThreadItemSchema = z.discriminatedUnion('type', [...]) +export const CodexTurnSchema = z.object({ + id: z.string().min(1), + items: z.array(CodexThreadItemSchema), + status: CodexTurnStatusSchema, + error: z.object({ + message: z.string(), + codexErrorInfo: z.unknown().nullable(), + additionalDetails: z.string().nullable(), + }).nullable().optional().default(null), + startedAt: z.number().nullable().optional().default(null), + completedAt: z.number().nullable().optional().default(null), + durationMs: z.number().nullable().optional().default(null), +}) +export const CodexThreadSchema = z.object({ + id: z.string().min(1), + forkedFromId: z.string().nullable().optional().default(null), + preview: z.string(), + ephemeral: z.boolean(), + modelProvider: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + status: CodexThreadStatusSchema, + path: z.string().nullable().optional().default(null), + cwd: z.string().min(1), + cliVersion: z.string(), + source: CodexSessionSourceSchema, + agentNickname: z.string().nullable().optional().default(null), + agentRole: z.string().nullable().optional().default(null), + gitInfo: CodexGitInfoSchema.nullable().optional().default(null), + name: z.string().nullable().optional().default(null), + turns: z.array(CodexTurnSchema), +}).passthrough() +export const CodexServerRequestSchema = z.discriminatedUnion('method', [...]) +export const CodexServerNotificationSchema = z.discriminatedUnion('method', [...]) +``` + +The object schemas and discriminated unions must be generated-schema faithful enough that Task 5 fixtures cannot use impossible app-server thread, turn, item, request, response, or notification shapes. It is acceptable to use `.passthrough()` for extra future fields on known result/entity variants, but implemented client-request parameter schemas must be `.strict()` so stale outbound fields fail before reaching Codex. Do not make generated-required fields optional and do not use a catch-all unknown item variant. + +Model the legacy root approval request/response schemas explicitly alongside the v2 request schemas because the current generated `ServerRequest` union still includes them: + +```ts +export const CodexLegacyReviewDecisionSchema = z.union([ + z.enum(['approved', 'approved_for_session', 'denied', 'timed_out', 'abort']), + z.object({ + approved_execpolicy_amendment: z.object({ + proposed_execpolicy_amendment: z.record(z.string(), JsonValue), + }), + }), + z.object({ + network_policy_amendment: z.object({ + network_policy_amendment: z.record(z.string(), JsonValue), + }), + }), +]) + +export const CodexLegacyApplyPatchApprovalParamsSchema = z.object({ + conversationId: z.string().min(1), + callId: z.string().min(1), + fileChanges: z.record(z.string(), JsonValue), + reason: z.string().nullable().optional().default(null), + grantRoot: z.string().nullable().optional().default(null), +}) +export const CodexLegacyExecCommandApprovalParamsSchema = z.object({ + conversationId: z.string().min(1), + callId: z.string().min(1), + command: z.array(z.string()), + cwd: z.string(), + parsedCmd: z.array(JsonValue), + approvalId: z.string().nullable().optional().default(null), + reason: z.string().nullable().optional().default(null), +}) +export const CodexLegacyApplyPatchApprovalResponseSchema = z.object({ + decision: CodexLegacyReviewDecisionSchema, +}) +export const CodexLegacyExecCommandApprovalResponseSchema = z.object({ + decision: CodexLegacyReviewDecisionSchema, +}) +``` + +Do not route these legacy requests through the runtime-global/auth-refresh path just because their params do not have `threadId`; their generated `conversationId` is the Codex thread id. + +Create `transport.ts` as the only app-server framing owner: + +```ts +export type CodexRpcMessage = { + id?: string | number + method?: string + params?: unknown + result?: unknown + error?: unknown +} + +export interface CodexAppServerTransport { + send(message: CodexRpcMessage): Promise<void> + onMessage(listener: (message: CodexRpcMessage) => void): () => void + close(): Promise<void> +} + +export class CodexStdioJsonlTransport implements CodexAppServerTransport {} +export class CodexWebSocketTransport implements CodexAppServerTransport {} +``` + +The stdio implementation should split stdout on newlines, parse one JSON message per line, reject malformed app-server output with a clear transport error, and never add a `jsonrpc` property. The websocket implementation should preserve the existing loopback app-server terminal launch behavior while using the same no-`jsonrpc` envelope semantics as stdio. + +Update `client.ts`: + +```ts +type CodexRequestId = string | number +type ServerRequest = { id: CodexRequestId; method: string; params: unknown } + +onNotification(listener: (notification: { method: string; params?: unknown }) => void): () => void +onServerRequest(listener: (request: ServerRequest) => void): () => void +respondToServerRequest(id: CodexRequestId, result: unknown): Promise<void> +respondToServerRequestError(id: CodexRequestId, error: { code: number; message: string; data?: unknown }): Promise<void> +readThread(params: CodexThreadReadParams): Promise<CodexThreadReadResult> +listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResult> +listThreads(params: CodexThreadListParams): Promise<CodexThreadListResult> +listLoadedThreads(params: CodexThreadLoadedListParams): Promise<CodexThreadLoadedListResult> +startTurn(params: CodexTurnStartParams): Promise<CodexTurnStartResult> +interruptTurn(params: CodexTurnInterruptParams): Promise<CodexTurnInterruptResult> +forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResult> +startReview(params: CodexReviewStartParams): Promise<CodexReviewStartResult> +listModels(params: CodexModelListParams): Promise<CodexModelListResult> +readModelProviderCapabilities(params: CodexModelProviderCapabilitiesReadParams): Promise<CodexModelProviderCapabilitiesReadResult> +``` + +Update message handling so app-server requests with `id` and `method` are not ignored, and so notifications without `id` reach subscribers. Keep request timeout behavior for client-initiated calls. `initialize` must send `capabilities.experimentalApi: false` because the checked-in protocol snapshot and method classification are non-experimental; do not send `experimentalApi: true` unless this plan is updated to generate and classify `--experimental` schema artifacts. `initialize` also must not opt out of any visible-state notification method; remove the existing `thread/started` opt-out. After a successful `initialize`, send exactly one `initialized` notification on the same transport before non-initialize requests. The client constructor should receive a `CodexAppServerTransport` instead of a `{ wsUrl }` endpoint. Server-request responses must support both result and error envelopes so unsupported required requests such as auth-token refresh can unblock the app-server without sending an invalid success shape. + +Keep `runtime.ts` as the websocket remote runtime for raw Codex terminal panes and `CodexLaunchPlanner`. It should spawn: + +```ts +spawn(command, [...commandArgs, 'app-server', '--listen', wsUrl], { + stdio: ['ignore', 'pipe', 'pipe'], +}) +``` + +and use `CodexWebSocketTransport` internally. Its `startThread` and `resumeThread` continue returning `{ threadId, wsUrl }`; do not break `server/terminal-registry.ts`, `server/agent-api/router.ts`, or `CodexLaunchPlanner`. + +Create `rich-runtime.ts` for Freshcodex. It should spawn: + +```ts +spawn(command, [...commandArgs, 'app-server', '--listen', 'stdio://'], { + stdio: ['pipe', 'pipe', 'pipe'], +}) +``` + +and use `CodexStdioJsonlTransport` internally. Proxy the new rich methods after `ensureReady()`. Freshcodex adapter dependencies must use this rich runtime and must not receive or depend on `wsUrl`. + +`rich-runtime.ts` must also expose: + +```ts +subscribe(threadId: string, listener: (event: CodexRuntimeEvent) => void): Promise<() => void> +onServerRequest(listener: (request: CodexServerRequest) => void): () => void +onRuntimeError(listener: (error: CodexRuntimeError) => void): () => void +``` + +The runtime should forward notifications and server requests from `client.ts` without buffering them behind a snapshot call. Server requests use `params.threadId` for v2 requests and `params.conversationId` for legacy root approvals. Notifications need their own generated-method-specific router: common thread-visible notifications use `params.threadId`, `thread/started` uses `params.thread.id`, `warning` is thread-visible only when `params.threadId` is non-null, and connection-scoped/global notifications such as `command/exec/outputDelta`, `fs/changed`, `configWarning`, MCP startup/OAuth status, and Windows sandbox warnings have no Freshcodex thread locator. Notifications or server requests without a thread locator but with visible global impact, such as app-server errors, null-thread warnings, and `account/chatgptAuthTokens/refresh`, should still reach subscribers as typed runtime events or runtime errors. They must not be attached to whichever thread happened to subscribe. + +Keep the branch typecheckable at the end of Task 4. Because this task removes the nonexistent `thread/turn/read` client/runtime API, also update `server/fresh-agent/adapters/codex/adapter.ts` enough to stop depending on `readThreadTurn` or a websocket-only `{ wsUrl }` result. This is a narrow compile-preserving bridge before Task 5's full normalization: + +```ts +type CodexFreshAgentRichRuntimePort = { + startThread(params: CodexThreadStartParams): Promise<{ threadId: string }> + resumeThread(params: CodexThreadResumeParams): Promise<{ threadId: string }> + readThread(params: CodexThreadReadParams): Promise<CodexThreadReadResult> + listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResult> + subscribe?(threadId: string, listener: (event: unknown) => void): Promise<() => void> | (() => void) +} +``` + +For this bridge only, `getTurnBody` should return a typed `FreshAgentUnsupportedCapabilityError` or cache-miss style error rather than calling a fake Codex RPC. Task 5 replaces that bridge with the bounded page/body cache and full event/request handling. Do not keep any `readThreadTurn` method on the client, raw websocket runtime, rich stdio runtime, or adapter port. + +Wire both Codex runtimes in `server/index.ts` in the same task: + +```ts +const codexAppServerRuntime = new CodexAppServerRuntime() +const codexRichAppServerRuntime = new CodexRichAppServerRuntime() +const codexLaunchPlanner = new CodexLaunchPlanner(codexAppServerRuntime) +const codexFreshAgentAdapter = createCodexFreshAgentAdapter({ + runtime: codexRichAppServerRuntime, +}) +``` + +The raw websocket runtime remains exclusively for `CodexLaunchPlanner` and raw Codex terminal `--remote` attach. The rich stdio runtime is passed to the Freshcodex adapter. On server shutdown, call `await codexRichAppServerRuntime.shutdown()` next to the existing raw runtime shutdown so the stdio app-server process cannot be orphaned. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/coding-cli/codex-app-server/transport.test.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Keep `client.ts` as the only JSON-RPC envelope owner. `runtime.ts` and `rich-runtime.ts` should remain thin lifecycle/proxy layers with separate responsibilities. + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/coding-cli/codex-app-server/transport.test.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/integration/server/codex-session-flow.test.ts +npm run audit:codex-app-server-schema +npm run typecheck:server +``` + +Expected: PASS. If `npm run audit:codex-app-server-schema` fails because the installed `codex` schema differs from the checked-in snapshot or because the traceability matrix has unclassified generated surfaces, do not proceed by weakening tests; regenerate the snapshot, update protocol schemas, update traceability classifications, and rerun this task. + +- [ ] **Step 6: Commit** + +```bash +git add \ + server/index.ts \ + server/coding-cli/codex-app-server/protocol.ts \ + server/coding-cli/codex-app-server/transport.ts \ + server/coding-cli/codex-app-server/client.ts \ + server/coding-cli/codex-app-server/rich-runtime.ts \ + server/coding-cli/codex-app-server/runtime.ts \ + server/coding-cli/codex-app-server/launch-planner.ts \ + server/fresh-agent/adapters/codex/adapter.ts \ + package.json \ + test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ClientRequest.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerRequest.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerNotification.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/RequestId.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/RequestId.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCRequest.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCError.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCNotification.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCMessage.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalParams.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalParams.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadReadResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadStartResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ModelListResponse.json \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReasoningEffort.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InputModality.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/SubAgentSource.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReviewDecision.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionRequestApprovalResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/FileChangeRequestApprovalResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PermissionsRequestApprovalResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ToolRequestUserInputResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpServerElicitationRequestResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ChatgptAuthTokensRefreshResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/AskForApproval.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxMode.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxPolicy.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/NetworkAccess.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Thread.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Turn.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnError.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadItem.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/UserInput.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TextElement.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ByteRange.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/HookPromptFragment.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/MessagePhase.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitation.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitationEntry.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandAction.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionSource.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallResult.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallError.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallOutputContentItem.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/WebSearchAction.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadActiveFlag.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchApplyStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchChangeKind.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CollabAgentToolCallStatus.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SessionSource.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartSource.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSourceKind.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSortKey.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SortDirection.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Model.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReasoningEffortOption.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListResponse.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadParams.ts \ + test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadResponse.ts \ + test/fixtures/coding-cli/codex-app-server/schema-inventory.ts \ + test/fixtures/coding-cli/codex-app-server/schema-traceability.ts \ + scripts/audit-codex-app-server-schema.ts \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/coding-cli/codex-app-server/transport.test.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts \ + test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/integration/server/codex-session-flow.test.ts +git commit -m "Extend Codex app-server client for rich turns" +``` + +### Task 5: Fully Normalize Codex Snapshots, Pages, Bodies, And Events + +**Files:** +- Modify: `server/coding-cli/codex-app-server/rich-runtime.ts` +- Modify: `server/fresh-agent/adapters/codex/normalize.ts` +- Modify: `server/fresh-agent/adapters/codex/adapter.ts` +- Modify: `server/fresh-agent/runtime-adapter.ts` +- Modify: `server/fresh-agent/runtime-manager.ts` +- Modify: `shared/ws-protocol.ts` +- Modify: `server/ws-handler.ts` +- Modify: `src/lib/fresh-agent-ws.ts` +- Modify: `src/store/paneTypes.ts` +- Modify: `test/fixtures/fresh-agent/codex/contract-fixtures.ts` +- Modify: `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts` +- Modify: `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` +- Test: `test/unit/server/fresh-agent/codex-normalize.test.ts` +- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` +- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` +- Test: `test/unit/client/lib/fresh-agent-ws.test.ts` + +- [ ] **Step 1: Write failing normalization and event tests** + +Require all documented Codex item variants to normalize into `FreshAgentTranscriptItemSchema` variants. Build every raw fixture with a helper that first parses the fixture through `CodexThreadItemSchema` or `CodexTurnSchema`: + +Drive this fixture list from `schema-traceability.ts`, not from hand-maintained test arrays. The normalization test should iterate every traceability entry with `generatedKind: 'threadItem'` and require either a `normalizer` plus `freshAgentSchema` fixture that parses, or an `intentionalOmission` with a typed supported-negative behavior. The concrete examples below are minimum regression fixtures for the historical nonconvergence findings; the traceability matrix is what proves the list is exhaustive. + +```ts +function parseCodexItemFixture(value: unknown): CodexThreadItem { + return CodexThreadItemSchema.parse(value) +} + +function parseCodexTurnFixture(value: unknown): CodexTurn { + return CodexTurnSchema.parse(value) +} +``` + +If the generated schema requires fields not shown in a short example below, the test fixture must include those fields. For Codex CLI 0.128.0, for example, `commandExecution` requires `cwd`, `source`, and `commandActions`; `agentMessage` includes `phase` and `memoryCitation`; `imageGeneration` includes `result`; and `contextCompaction` contains only `id`. + +```ts +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'userMessage', + id: 'u1', + content: [{ type: 'text', text: 'Do it', text_elements: [{ byteRange: { start: 0, end: 2 }, placeholder: null }] }], +}))) + .toEqual([{ id: 'u1', kind: 'message', role: 'user', content: [{ kind: 'text', text: 'Do it', textElements: [{ byteRange: { start: 0, end: 2 }, placeholder: null }] }] }]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'userMessage', + id: 'u2', + content: [ + { type: 'text', text: 'Use this mockup', text_elements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] }, + { type: 'image', url: 'https://example.test/mockup.png' }, + { type: 'localImage', path: '/tmp/mockup.png' }, + { type: 'mention', name: 'README.md', path: '/repo/README.md' }, + { type: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' }, + ], +}))).toEqual([{ + id: 'u2', + kind: 'message', + role: 'user', + content: [ + { kind: 'text', text: 'Use this mockup', textElements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] }, + { kind: 'image', url: 'https://example.test/mockup.png' }, + { kind: 'image', path: '/tmp/mockup.png' }, + { kind: 'mention', name: 'README.md', path: '/repo/README.md' }, + { kind: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' }, + ], +}]) + +expect(normalizeCodexItem(parseCodexItemFixture({ type: 'hookPrompt', id: 'h1', fragments: [{ text: 'Preflight', hookRunId: 'hook-1' }] }))) + .toEqual([expect.objectContaining({ id: 'h1', kind: 'hook_prompt', fragments: [{ text: 'Preflight', hookRunId: 'hook-1' }] })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'agentMessage', + id: 'a1', + text: 'Done', + phase: 'final_answer', + memoryCitation: { + entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }], + threadIds: ['thread-memory-1'], + }, +}))) + .toEqual([{ + id: 'a1', + kind: 'message', + role: 'assistant', + phase: 'final_answer', + memoryCitation: { + entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }], + threadIds: ['thread-memory-1'], + }, + content: [{ kind: 'text', text: 'Done' }], + }]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'reasoning', + id: 'r1', + summary: ['Checked repository state'], + content: ['First reasoning paragraph', 'Second reasoning paragraph'], +}))) + .toEqual([expect.objectContaining({ + id: 'r1', + kind: 'reasoning', + summary: ['Checked repository state'], + content: ['First reasoning paragraph', 'Second reasoning paragraph'], + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'commandExecution', + id: 'c1', + command: 'npm test', + cwd: '/repo', + processId: null, + source: 'agent', + status: 'completed', + commandActions: [{ type: 'search', command: 'rg Freshcodex', query: 'Freshcodex', path: '/repo' }], + aggregatedOutput: 'ok', + exitCode: 0, + durationMs: 10, +}))) + .toEqual([expect.objectContaining({ + id: 'c1', + kind: 'command', + command: 'npm test', + source: 'agent', + commandActions: [{ type: 'search', command: 'rg Freshcodex', query: 'Freshcodex', path: '/repo' }], + status: 'completed', + output: 'ok', + durationMs: 10, + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'commandExecution', + id: 'c-running', + command: 'npm test', + cwd: '/repo', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, +}))) + .toEqual([expect.objectContaining({ id: 'c-running', kind: 'command', status: 'running' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ type: 'fileChange', id: 'f1', status: 'completed', changes: [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '@@' }] }))) + .toEqual([expect.objectContaining({ id: 'f1', kind: 'file_change', changes: [{ path: 'src/a.ts', changeKind: 'modify', diff: '@@' }] })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'fileChange', + id: 'f-rename', + status: 'completed', + changes: [{ path: 'src/new-name.ts', kind: { type: 'update', move_path: 'src/old-name.ts' }, diff: '@@' }], +}))) + .toEqual([expect.objectContaining({ + id: 'f-rename', + kind: 'file_change', + changes: [{ path: 'src/new-name.ts', changeKind: 'rename', movePath: 'src/old-name.ts', diff: '@@' }], + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'fileChange', + id: 'f-running', + status: 'inProgress', + changes: [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '' }], +}))) + .toEqual([expect.objectContaining({ id: 'f-running', kind: 'file_change', status: 'running' })]) + +expect(() => normalizeCodexItem({ type: 'newUnknownItem', id: 'u1' })) + .toThrow(/unsupported Codex item/i) + +expect(normalizeCodexItem(parseCodexItemFixture({ type: 'contextCompaction', id: 'compact-1' }))) + .toEqual([expect.objectContaining({ id: 'compact-1', kind: 'context_compaction' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'dynamicToolCall', + id: 'dyn-1', + namespace: null, + tool: 'tool-x', + arguments: ['non-object', { ok: true }], + status: 'failed', + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, + durationMs: null, +}))) + .toEqual([expect.objectContaining({ + id: 'dyn-1', + kind: 'dynamic_tool', + namespace: null, + input: ['non-object', { ok: true }], + status: 'failed', + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'mcpToolCall', + id: 'mcp-completed', + server: 'fixture-server', + tool: 'fixture-tool', + status: 'completed', + arguments: 'raw-string-argument', + mcpAppResourceUri: 'mcp://fixture/resource', + result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { ok: true }, _meta: null }, + error: null, + durationMs: 12, +}))) + .toEqual([expect.objectContaining({ + id: 'mcp-completed', + kind: 'tool', + server: 'fixture-server', + name: 'fixture-tool', + status: 'completed', + input: 'raw-string-argument', + mcpAppResourceUri: 'mcp://fixture/resource', + result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { ok: true }, _meta: null }, + durationMs: 12, + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'mcpToolCall', + id: 'mcp-running', + server: 'fixture-server', + tool: 'fixture-tool', + status: 'inProgress', + arguments: {}, + result: null, + error: null, + durationMs: null, +}))) + .toEqual([expect.objectContaining({ id: 'mcp-running', kind: 'tool', server: 'fixture-server', name: 'fixture-tool', status: 'running' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'dynamicToolCall', + id: 'dyn-running', + namespace: 'fixture-namespace', + tool: 'tool-x', + arguments: {}, + status: 'inProgress', + contentItems: null, + success: null, + durationMs: null, +}))) + .toEqual([expect.objectContaining({ id: 'dyn-running', kind: 'dynamic_tool', namespace: 'fixture-namespace', status: 'running' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'collabAgentToolCall', + id: 'collab-running', + tool: 'spawnAgent', + status: 'inProgress', + senderThreadId: 'thread-1', + receiverThreadIds: ['thread-child-1', 'thread-child-2'], + prompt: 'Review this', + model: 'configured-model', + reasoningEffort: 'high', + agentsStates: { + 'thread-child-1': { status: 'running', message: 'Working' }, + 'thread-child-2': { status: 'completed', message: null }, + }, +}))) + .toEqual([expect.objectContaining({ + id: 'collab-running', + kind: 'collaboration', + status: 'running', + receiverThreadIds: ['thread-child-1', 'thread-child-2'], + agentsStates: expect.objectContaining({ 'thread-child-1': expect.any(Object) }), + })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ type: 'imageGeneration', id: 'img-gen-1', status: 'completed', revisedPrompt: 'diagram', result: 'https://example.test/generated.png' }))) + .toEqual([expect.objectContaining({ id: 'img-gen-1', kind: 'image_generation', prompt: 'diagram', result: 'https://example.test/generated.png' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ type: 'imageGeneration', id: 'img-gen-custom', status: 'provider_specific_status', revisedPrompt: null, result: 'https://example.test/generated.png', savedPath: '/repo/generated.png' }))) + .toEqual([expect.objectContaining({ id: 'img-gen-custom', kind: 'image_generation', status: 'provider_specific_status', result: 'https://example.test/generated.png', savedPath: '/repo/generated.png' })]) + +expect(normalizeCodexItem(parseCodexItemFixture({ + type: 'webSearch', + id: 'web-1', + query: 'Freshcodex', + action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' }, +}))) + .toEqual([expect.objectContaining({ + id: 'web-1', + kind: 'web_search', + query: 'Freshcodex', + action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' }, + })]) +``` + +Add table-driven coverage for every local generated `ThreadItem` type: `userMessage`, `hookPrompt`, `agentMessage`, `plan`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`, `enteredReviewMode`, `exitedReviewMode`, and `contextCompaction`. The table must derive the expected type names from `schema-inventory.ts` and fail if the checked-in generated `ThreadItem.ts` contains a variant with no schema-valid fixture. + +Require adapter methods: + +```ts +runtime.startTurn.mockResolvedValue({ turn: schemaValidTurn({ id: 'turn-1' }) }) +await adapter.send?.('thread-1', { text: 'Ship it' }) +expect(runtime.startTurn).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-1', + input: [{ type: 'text', text: 'Ship it', text_elements: [] }], +})) + +await adapter.send?.('thread-1', { + text: 'Use this mockup', + images: [{ kind: 'url', url: 'https://example.test/mockup.png', mediaType: 'image/png' }], + runtimeSettings: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', + }, +}) +expect(runtime.startTurn).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-1', + model: 'configured-model', + sandboxPolicy: expect.anything(), + approvalPolicy: expect.anything(), + effort: 'xhigh', + input: [ + { type: 'text', text: 'Use this mockup', text_elements: [] }, + { type: 'image', url: 'https://example.test/mockup.png' }, + ], +})) + +await adapter.send?.('thread-1', { + images: [ + { kind: 'data', mediaType: 'image/png', data: 'AQID' }, + { kind: 'local', path: '/repo/mockup.png', mediaType: 'image/png' }, + ], +}) +expect(runtime.startTurn).toHaveBeenLastCalledWith(expect.objectContaining({ + threadId: 'thread-1', + input: [ + { type: 'image', url: 'data:image/png;base64,AQID' }, + { type: 'localImage', path: '/repo/mockup.png' }, + ], +})) + +await expect(adapter.send?.('thread-1', { + text: 'Invalid codex settings', + runtimeSettings: { permissionMode: 'bypassPermissions', effort: 'max' }, +})).rejects.toMatchObject({ code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' }) + +await adapter.interrupt?.('thread-1') +expect(runtime.interruptTurn).toHaveBeenCalledWith({ threadId: 'thread-1', turnId: 'turn-1' }) + +await expect(adapter.interrupt?.('thread-without-active-turn')) + .rejects.toMatchObject({ code: 'FRESH_AGENT_NO_ACTIVE_TURN' }) + +runtime.readThread.mockResolvedValue({ + thread: schemaValidThread({ + id: 'thread-resumed-running', + status: { type: 'active', activeFlags: [] }, + turns: [], + }), +}) +runtime.listThreadTurns.mockResolvedValue({ + data: [schemaValidTurn({ id: 'turn-running-1', status: 'inProgress', items: [] })], + nextCursor: null, + backwardsCursor: null, +}) +await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-resumed-running' }) +await adapter.interrupt?.('thread-resumed-running') +expect(runtime.interruptTurn).toHaveBeenCalledWith({ + threadId: 'thread-resumed-running', + turnId: 'turn-running-1', +}) + +runtime.forkThread.mockResolvedValue(schemaValidThreadLifecycleResult({ thread: schemaValidThread({ id: 'thread-fork-1', turns: [] }) })) +await expect(adapter.fork?.('thread-1', { excludeTurns: true })) + .resolves.toMatchObject({ sessionId: 'thread-fork-1', parentThreadId: 'thread-1' }) + +runtime.readThread + .mockRejectedValueOnce({ code: 'FRESH_AGENT_LOST_SESSION' }) + .mockResolvedValueOnce({ thread: schemaValidThread({ id: 'thread-restored-1', turns: [] }) }) +runtime.resumeThread.mockResolvedValue(schemaValidThreadLifecycleResult({ + thread: schemaValidThread({ id: 'thread-restored-1', turns: [] }), +})) +await adapter.getSnapshot?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-restored-1', +}, { + cwd: '/repo', + runtimeSettings: { model: 'configured-model', sandbox: 'workspace-write', permissionMode: 'on-request', effort: 'xhigh' }, +}) +expect(runtime.resumeThread).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-restored-1', + excludeTurns: true, + cwd: '/repo', +})) +expect(runtime.listThreadTurns).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-restored-1', + limit: expect.any(Number), +})) + +await expect(adapter.startReview?.('thread-1')).resolves.toMatchObject({ + turnId: expect.any(String), + reviewThreadId: 'thread-1', + target: { type: 'uncommittedChanges' }, + delivery: 'inline', +}) +expect(runtime.startReview).toHaveBeenCalledWith({ + threadId: 'thread-1', + target: { type: 'uncommittedChanges' }, + delivery: 'inline', +}) + +await expect(adapter.listThreads?.({ limit: 25 })).resolves.toMatchObject({ + sessionType: 'freshcodex', + provider: 'codex', + items: [expect.objectContaining({ sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' })], + nextCursor: null, + backwardsCursor: null, +}) + +runtime.listThreads.mockResolvedValue({ + data: [schemaValidThread({ + id: 'thread-child-1', + source: { subAgent: { thread_spawn: { + parent_thread_id: 'thread-parent-1', + depth: 1, + agent_path: null, + agent_nickname: 'reviewer', + agent_role: 'review', + } } }, + turns: [], + })], + nextCursor: null, + backwardsCursor: null, +}) +await expect(adapter.listThreads?.({ limit: 25 })).resolves.toMatchObject({ + items: [expect.objectContaining({ + sessionId: 'thread-child-1', + source: expect.objectContaining({ + subAgent: expect.objectContaining({ + thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }), + }), + }), + parentThreadId: 'thread-parent-1', + })], +}) + +runtime.listModels.mockResolvedValue({ + data: [schemaValidModel({ id: 'model-page-1', model: 'model-page-1' })], + nextCursor: 'next-model-page', +}) +await expect(adapter.listModels?.({ limit: 25 })).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 'model-page-1' })], + nextCursor: 'next-model-page', +}) + +await expect(adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7, limit: 25, sortDirection: 'desc' })) + .resolves.toMatchObject({ provider: 'codex', threadId: 'thread-new-1' }) + +await adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7, limit: 25, sortDirection: 'desc' }) +await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' }, 7)) + .resolves.toMatchObject({ provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' }) +expect(runtime.readThread).not.toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: true }) +``` + +Require server-request approval mapping: + +```ts +emitServerRequest('item/commandExecution/requestApproval', { threadId: 'thread-1', turnId: 'turn-1', itemId: 'cmd-1', command: 'npm test' }) +expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.snapshot.invalidate' })) +expect(await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' })) + .toMatchObject({ pendingApprovals: [{ requestId: expect.stringContaining('cmd-1') }] }) +``` + +Add table-driven server-request coverage for every local generated `ServerRequest` method. Build each request with a helper that parses `{ id, method, params }` through `CodexServerRequestSchema` before the adapter sees it, because several request variants have required structured params beyond `threadId`. `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, and `item/permissions/requestApproval` become pending approvals; `item/tool/requestUserInput` and `mcpServer/elicitation/request` become pending questions; `item/tool/call` receives an explicit generated-shape dynamic-tool result such as `{ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], success: false }`; `account/chatgptAuthTokens/refresh` receives a JSON-RPC error response on the same request id because its success shape requires real token fields and, because it has no thread locator, also emits a runtime-global `freshAgent.error` or equivalent runtime event to all subscribed Freshcodex panes for that rich runtime. Legacy root `applyPatchApproval` and `execCommandApproval` are mapped to pending approval prompts when generated schema still includes them, but their thread locator is `params.conversationId`, not `params.threadId`, and their response decision schema is root `ReviewDecision`, not v2 command/file approval decisions. `serverRequest/resolved` must remove matching pending approval/question/request state by generated `requestId`. + +The same table must verify response serialization for each interactive request method through the fresh-agent action contract, not only through low-level client tests: + +```ts +await adapter.respondToServerRequest?.('thread-1', { + requestId: 42, + kind: 'tool_user_input', + answers: { choice: { answers: ['a'] } }, +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith(42, { + answers: { choice: { answers: ['a'] } }, +}) + +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'user-input-1', + kind: 'tool_user_input', + answers: { choice: { answers: ['a'] } }, +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('user-input-1', { + answers: { choice: { answers: ['a'] } }, +}) + +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'mcp-elicit-1', + kind: 'mcp_elicitation', + action: 'accept', + content: { selected: true }, + _meta: null, +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('mcp-elicit-1', { + action: 'accept', + content: { selected: true }, + _meta: null, +}) + +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'permissions-1', + kind: 'permissions_approval', + permissions: grantedPermissionFixture, + scope: 'session', +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('permissions-1', { + permissions: grantedPermissionFixture, + scope: 'session', +}) + +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'dynamic-tool-1', + kind: 'dynamic_tool', + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('dynamic-tool-1', { + contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], + success: false, +}) + +emitSchemaValidServerRequest({ + id: 'legacy-patch-request-1', + method: 'applyPatchApproval', + params: { + conversationId: 'thread-1', + callId: 'patch-1', + fileChanges: {}, + }, +}) +expect(await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' })) + .toMatchObject({ pendingApprovals: [expect.objectContaining({ requestId: expect.anything() })] }) + +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'legacy-patch-request-1', + kind: 'legacy_patch_approval', + decision: 'approved', +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('legacy-patch-request-1', { + decision: 'approved', +}) + +emitSchemaValidServerRequest({ + id: 'legacy-exec-request-1', + method: 'execCommandApproval', + params: { + conversationId: 'thread-1', + callId: 'exec-1', + approvalId: null, + command: ['npm', 'test'], + cwd: '/repo', + parsedCmd: [], + }, +}) +await adapter.respondToServerRequest?.('thread-1', { + requestId: 'legacy-exec-request-1', + kind: 'legacy_exec_approval', + decision: 'denied', +}) +expect(runtime.respondToServerRequest).toHaveBeenCalledWith('legacy-exec-request-1', { + decision: 'denied', +}) +``` + +Add table-driven notification coverage for every local generated `ServerNotification` method that can change visible Freshcodex state: + +```ts +function emitSchemaValidNotification(method: string, overrides: Record<string, unknown> = {}) { + const params = schemaValidNotificationParams(method, overrides) + const notification = CodexServerNotificationSchema.parse({ method, params }) + emitNotification(notification.method, notification.params) +} + +it.each([ + ['thread/started'], + ['turn/started'], + ['turn/completed'], + ['item/started'], + ['item/completed'], + ['thread/status/changed'], + ['thread/tokenUsage/updated'], + ['turn/diff/updated'], + ['turn/plan/updated'], + ['thread/compacted'], + ['thread/name/updated'], + ['thread/closed'], + ['thread/archived'], + ['thread/realtime/error'], +])('invalidates the Freshcodex snapshot for %s notifications', async (method) => { + const listener = vi.fn() + await adapter.subscribe?.('thread-1', listener) + emitSchemaValidNotification(method, { threadId: 'thread-1' }) + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.snapshot.invalidate', + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-1', + reason: method, + })) +}) +``` + +If the generated schema uses different method names or params, use the generated names and generated params in the test table. The executor must add every visible-state notification method present in `ServerNotification.json`; do not shrink the table to the example above. Do not emit `{ threadId }`-only fake notifications for methods whose generated params require a `Turn`, `ThreadItem`, token-usage object, realtime payload, or another structured body; every notification fixture must parse through `CodexServerNotificationSchema` before it reaches the adapter. + +Also add negative routing tests for generated notifications with no Freshcodex thread locator: + +```ts +it.each([ + ['command/exec/outputDelta', { processId: 'process-1', stream: 'stdout', deltaBase64: '', capReached: false }], + ['fs/changed', { watchId: 'watch-1', changedPaths: ['/repo/file.ts'] }], + ['warning', { threadId: null, message: 'Runtime warning' }], +])('does not invalidate the subscribed thread for %s without a thread locator', async (method, params) => { + const listener = vi.fn() + await adapter.subscribe?.('thread-1', listener) + emitNotification(method, CodexServerNotificationSchema.parse({ method, params }).params) + expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.snapshot.invalidate', + threadId: 'thread-1', + reason: method, + })) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/fresh-agent/codex-normalize.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts +``` + +Expected: FAIL because Codex items are still raw-ish and actions are incomplete. + +- [ ] **Step 3: Implement normalization and action adapter** + +In `normalize.ts`, expose focused pure helpers: + +```ts +export function normalizeCodexThreadStatus(raw: unknown): FreshAgentThreadStatus +export function normalizeCodexItem(raw: unknown): FreshAgentTranscriptItem[] +export function normalizeCodexTurnBody(input: { sessionType: 'freshcodex'; provider: 'codex'; threadId: string; revision: number; rawTurn: CodexTurn }): FreshAgentTurnBody +export function normalizeCodexTurnPage(input: { threadId: string; revision: number; page: CodexThreadTurnsListResult }): FreshAgentTurnPage +export function normalizeCodexThreadSnapshot(input: ...): FreshAgentThreadSnapshot +``` + +Map generated Codex status objects explicitly. The current app-server schema represents thread status as `{ type: 'notLoaded' | 'idle' | 'systemError' | 'active', activeFlags: [...] }`, not as a bare string. Preserve active flags such as `waitingOnApproval` and `waitingOnUserInput` under the Codex extension while mapping them to a shared running status: + +```ts +export function normalizeCodexThreadStatus(raw: unknown): FreshAgentThreadStatus { + const parsed = CodexThreadStatusSchema.parse(raw) + switch (parsed.type) { + case 'notLoaded': + case 'idle': + return 'idle' + case 'systemError': + return 'error' + case 'active': + return 'running' + } +} +``` + +Throw a clear `UnsupportedCodexItemError` for item types not intentionally modeled. Normalize actual app-server shapes from the generated `Thread` / `Turn` / `ThreadItem` schemas: + +Map generated item statuses through one small helper and use it for every transcript item kind that carries a status: + +```ts +function normalizeCodexItemStatus(status: 'inProgress' | 'completed' | 'failed' | 'declined'): 'running' | 'completed' | 'failed' | 'declined' { + return status === 'inProgress' ? 'running' : status +} +``` + +Do not reuse `normalizeCodexThreadStatus` or `TurnStatus` handling for item statuses; those are different generated leaf types. Tests must cover the active `"inProgress"` case for command executions, file changes, MCP tool calls, dynamic tool calls, and collab-agent tool calls because active live updates are the path most likely to hit the mismatch. + +Map generated patch-change kinds through another focused helper: + +```ts +function normalizeCodexPatchChangeKind(kind: CodexPatchChangeKind): { changeKind: 'add' | 'modify' | 'delete' | 'rename'; movePath?: string } { + switch (kind.type) { + case 'add': + return { changeKind: 'add' } + case 'delete': + return { changeKind: 'delete' } + case 'update': + return kind.move_path + ? { changeKind: 'rename', movePath: kind.move_path } + : { changeKind: 'modify' } + } +} +``` + +Use this helper for every `fileChange.changes[]` entry before parsing the normalized item through `FreshAgentFileChangeItemSchema`; do not drop `move_path`. + +```ts +export function normalizeCodexThreadSnapshot(input: { + thread: CodexThread + normalizedRevision: number + pendingApprovals: PendingCodexApproval[] + pendingQuestions: PendingCodexQuestion[] + tokenUsage?: FreshAgentThreadSnapshot['tokenUsage'] +}): FreshAgentThreadSnapshot + +export function normalizeCodexTurnPage(input: { + sessionType: 'freshcodex' + provider: 'codex' + threadId: string + revision: number + page: CodexThreadTurnsListResult // { data, nextCursor, backwardsCursor } +}): FreshAgentTurnPage +``` + +Do not read `rawSnapshot.revision`, `rawSnapshot.turns`, or `page.turns`; those are stale assumptions from Freshell's provisional protocol. Use `raw.thread` from `thread/read { includeTurns: false }` for snapshot metadata and `page.data` from `thread/turns/list` for turn bodies. `normalizeCodexTurnPage` should place each page turn's normalized body on the matching `FreshAgentTurnSummary.body` and should also populate the adapter's bounded turn-body cache. +Do not require or synthesize a turn-level role for Codex. `normalizeCodexTurnBody` must set `role` only when a legacy provider supplies one, and must preserve Codex user/assistant roles on `message` transcript items. `normalizeCodexItem` must return an array and `normalizeCodexTurnBody` must flatten those arrays: + +```ts +const items = rawTurn.items.flatMap((item) => normalizeCodexItem(item)) +const codexUnixSecondsToIso = (value: number | null | undefined) => ( + typeof value === 'number' ? new Date(value * 1000).toISOString() : undefined +) +return FreshAgentTurnBodySchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId, + turnId: rawTurn.id, + revision, + source: 'durable', + startedAt: codexUnixSecondsToIso(rawTurn.startedAt), + completedAt: codexUnixSecondsToIso(rawTurn.completedAt), + items, +}) +``` + +The `startedAt` and `completedAt` conversion is required because the local Codex app-server schema emits Unix seconds, while the fresh-agent UI contract uses ISO strings. Tests should include non-null numeric timestamps and assert the ISO conversion so the raw Codex schema is not accidentally modeled as strings. + +In `adapter.ts`, track per-thread ephemeral live state: + +```ts +type CodexLiveThreadState = { + pendingApprovals: Map<string, PendingCodexApproval> + pendingQuestions: Map<string, PendingCodexQuestion> + activeTurnId?: string + latestRevision?: number + tokenUsage?: FreshAgentThreadSnapshot['tokenUsage'] +} +``` + +Implement `adapter.subscribe(sessionId, listener)` for Codex by subscribing to the rich runtime notification stream and translating visible app-server events into fresh-agent events: + +```ts +return await runtime.subscribe(sessionId, (event) => { + if (isCodexServerRequest(event)) { + const routedThreadId = getServerRequestThreadId(event) // params.threadId for v2, params.conversationId for legacy root approvals + if (!routedThreadId) { + respondToUnsupportedRuntimeGlobalRequest(event) + listener({ type: 'freshAgent.error', sessionId, sessionType: 'freshcodex', provider: 'codex', code: 'FRESH_AGENT_UNSUPPORTED_AUTH_REFRESH', message: 'Freshell cannot refresh Codex ChatGPT auth tokens from this runtime.', retryable: false }) + return + } + updatePendingRequestState(routedThreadId, event) + listener({ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: routedThreadId, reason: event.method }) + return + } + if (isCodexServerNotification(event)) { + const route = getCodexNotificationRoute(event) + if (route.kind === 'thread') { + if (route.threadId !== sessionId) return + updateLiveThreadStateFromNotification(route.threadId, event) + listener({ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: route.threadId, reason: event.method }) + return + } + if (route.kind === 'runtimeGlobal') { + listener({ type: 'freshAgent.runtimeEvent', sessionType: 'freshcodex', provider: 'codex', event }) + return + } + if (route.kind === 'connectionScoped') { + logIgnoredConnectionScopedNotification(event.method, route.owner) + return + } + } +}) +``` + +`getCodexNotificationRoute` must be a generated-method-specific table, not a generic `params.threadId ?? sessionId` fallback. It must cover `thread/started` via `params.thread.id`, nullable-thread `warning`, and no-locator runtime/global or connection-scoped notifications. `turn/started` and `turn/completed` must update `activeTurnId`; `thread/tokenUsage/updated` must update `tokenUsage` in live state so the next snapshot rebuild includes current token counts; status, diff, review, compaction, item, metadata/name, close/archive, realtime error/close, and child-agent/collaboration notifications must invalidate the snapshot so every subscribed browser refreshes from the normalized app-server source. Non-visible notifications may be ignored only through an explicit allowlist with a comment naming why they do not affect the Freshcodex UI. +`getSnapshot` and `resume` must also recover `activeTurnId` without loading the full transcript. First read metadata with `thread/read { includeTurns: false }`, then fetch a bounded newest-first page with `thread/turns/list { limit: 10, sortDirection: 'desc' }` and select the newest `status: 'inProgress'` turn if present. This is required for interrupt to work after a browser reconnect, server restart, or adapter resubscription that missed the original `turn/started` notification while preserving long-transcript scalability. + +Implement `send`, `interrupt`, `fork`, and `respondToServerRequest` using the Freshcodex stdio rich runtime from Task 4, not the websocket launch planner runtime. `send` must store the active turn id from `turn/start -> { turn }`; `turn/started`, `turn/completed`, and runtime close/error notifications must keep `activeTurnId` current. `interrupt(locator)` remains the Fresh-agent API because the UI interrupts the active turn, but the Codex adapter must translate that to `turn/interrupt { threadId, turnId: activeTurnId }` and return a clear `FRESH_AGENT_NO_ACTIVE_TURN` action error if there is no active turn. `respondToServerRequest` must look up the pending request by generated request id, validate that the response `kind` matches the original generated server request method, serialize the generated response shape, and respond on the original JSON-RPC server request id. Do not keep separate `resolveApproval` / `answerQuestion` action paths for Codex; those names encourage collapsing permissions approvals, request-user-input prompts, and MCP elicitations into the wrong Claude-shaped payload. + +Add a single routing helper for generated server requests: + +```ts +function getServerRequestThreadId(request: CodexServerRequest): string | null { + if ('threadId' in request.params && typeof request.params.threadId === 'string') return request.params.threadId + if ('conversationId' in request.params && typeof request.params.conversationId === 'string') return request.params.conversationId + return null +} +``` + +Only `account/chatgptAuthTokens/refresh` should currently return `null`. Legacy `applyPatchApproval` and `execCommandApproval` must use `conversationId` to update the correct thread's pending approval state and must respond with `CodexLegacyApplyPatchApprovalResponseSchema` / `CodexLegacyExecCommandApprovalResponseSchema` payloads. + +Add a separate routing helper for generated server notifications: + +```ts +type CodexNotificationRoute = + | { kind: 'thread'; threadId: string } + | { kind: 'runtimeGlobal' } + | { kind: 'connectionScoped'; owner: 'unsupported-command-exec' | 'unsupported-fs-watch' | 'runtime-capability' } + | { kind: 'nonVisible' } + +function getCodexNotificationRoute(notification: CodexServerNotification): CodexNotificationRoute { + switch (notification.method) { + case 'thread/started': + return { kind: 'thread', threadId: notification.params.thread.id } + case 'warning': + return notification.params.threadId + ? { kind: 'thread', threadId: notification.params.threadId } + : { kind: 'runtimeGlobal' } + case 'command/exec/outputDelta': + return { kind: 'connectionScoped', owner: 'unsupported-command-exec' } + case 'fs/changed': + return { kind: 'connectionScoped', owner: 'unsupported-fs-watch' } + case 'configWarning': + case 'mcpServer/oauthLogin/completed': + case 'mcpServer/startupStatus/updated': + case 'windows/worldWritableWarning': + case 'windowsSandbox/setupCompleted': + return { kind: 'runtimeGlobal' } + default: + return hasGeneratedThreadId(notification.params) + ? { kind: 'thread', threadId: notification.params.threadId } + : { kind: 'nonVisible' } + } +} +``` + +The actual implementation should use an exhaustive table keyed by generated method name rather than the loose `default` above if that is clearer for TypeScript exhaustiveness. The important invariant is that no-locator notifications never use the subscriber's session id as a fake target thread. + +Carry runtime settings into both create/resume and turn start. Add `sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'` to `FreshAgentCreateRequest`, `FreshAgentPaneContent`, and the fresh-agent create WS payload. Replace the old create-message effort enum with the shared runtime-settings field schemas so Freshcodex create can carry generated Codex effort values such as `xhigh` and granular approval policy objects. The create parser must also resolve the effective provider from the session-type registry before accepting runtime settings; otherwise the broad persisted/UI settings schema would accept `bypassPermissions` and `max` for Freshcodex: + +```ts +const FreshAgentCreateBaseSchema = z.object({ + type: z.literal('freshAgent.create'), + requestId: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema.optional(), // omitted only when resolved from the session-type registry + cwd: z.string().optional(), + resumeSessionId: z.string().optional(), + model: FreshAgentRuntimeSettingsSchema.shape.model, + sandbox: FreshAgentRuntimeSettingsSchema.shape.sandbox, + permissionMode: FreshAgentRuntimeSettingsSchema.shape.permissionMode, + effort: FreshAgentRuntimeSettingsSchema.shape.effort, + plugins: z.array(z.string()).optional(), +}) + +export const FreshAgentCreateSchema = FreshAgentCreateBaseSchema.superRefine((value, ctx) => { + const provider = resolveRuntimeProviderForCreate(value.sessionType, value.provider) + validateFreshAgentRuntimeSettingFieldsForProvider(provider, value, ctx) +}) +``` + +Add explicit create-parser tests for both branches: + +```ts +expect(() => FreshAgentCreateSchema.parse({ + type: 'freshAgent.create', + requestId: 'create-codex-invalid', + sessionType: 'freshcodex', + provider: 'codex', + permissionMode: 'bypassPermissions', + effort: 'max', +})).toThrow(/FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING|permissionMode|effort/i) + +expect(FreshAgentCreateSchema.parse({ + type: 'freshAgent.create', + requestId: 'create-claude-valid', + sessionType: 'freshclaude', + provider: 'claude', + permissionMode: 'bypassPermissions', + effort: 'max', +})).toMatchObject({ provider: 'claude' }) +``` + +Implement `resolveRuntimeProviderForCreate` from the shared session descriptor source (`shared/fresh-agent.ts` or the split shared descriptor registry from Task 3), not from the client-only `src/lib/fresh-agent-registry.ts`. Implement `validateFreshAgentRuntimeSettingFieldsForProvider` by extracting `{ model, sandbox, permissionMode, effort }` from the parsed action and calling `FreshAgentCodexRuntimeSettingsSchema` or `FreshAgentClaudeRuntimeSettingsSchema` from `shared/fresh-agent-contract.ts`. Convert Zod failures into `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` action errors so the UI reports invalid migrated Freshcodex settings before a Codex app-server request is sent. + +Also update the server-to-client create response so the newly created session immediately carries the same locator shape: + +```ts +| { + type: 'freshAgent.created' + requestId: string + sessionId: string + sessionType: string + provider: string + runtimeProvider?: string + } +| { type: 'freshAgent.create.failed'; requestId: string; sessionType?: string; provider?: string; code: string; message: string; retryable?: boolean } +``` + +Resolve Freshcodex defaults from provider settings when the pane is created, then include `model`, `sandbox`, Codex-shaped `permissionMode` as generated `approvalPolicy`, and Codex-shaped `effort` in `thread/start`, `thread/resume`, and `turn/start` where the generated schema supports them. Tests must prove a pane with model/sandbox/permission/effort settings creates the Codex thread with those values and sends a later turn with the same values unless the user changes them. Tests must also prove Freshcodex rejects legacy Claude-only values (`permissionMode: 'bypassPermissions'`, `effort: 'max'`) before calling Codex app-server, including through `freshAgent.create` parsing rather than only through later send actions. + +Implement explicit runtime-setting mappers: + +```ts +import path from 'node:path' + +export function mapFreshcodexApprovalPolicy(value: FreshAgentRuntimeSettings['permissionMode']): CodexApprovalPolicy | undefined { + if (value === undefined) return undefined + return CodexApprovalPolicySchema.parse(value) +} + +export function mapFreshcodexReasoningEffort(value: FreshAgentRuntimeSettings['effort']): CodexReasoningEffort | undefined { + if (value === undefined) return undefined + return CodexReasoningEffortSchema.parse(value) +} + +export function mapFreshcodexSandboxModeToTurnPolicy( + sandbox: FreshAgentRuntimeSettings['sandbox'], + cwd: string | undefined, +): CodexSandboxPolicy | undefined { + switch (sandbox) { + case undefined: + return undefined + case 'danger-full-access': + return { type: 'dangerFullAccess' } + case 'read-only': + return { type: 'readOnly', networkAccess: false } + case 'workspace-write': + if (!cwd || !path.isAbsolute(cwd)) throw new FreshAgentUnsupportedRuntimeSettingError('workspace-write turn sandbox requires an absolute cwd') + return { + type: 'workspaceWrite', + writableRoots: [cwd], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + } + } +} +``` + +Use `sandbox` only for `thread/start`, `thread/resume`, and `thread/fork`; use `mapFreshcodexSandboxModeToTurnPolicy()` for `turn/start`. Do not pass the string `sandbox` field to `turn/start`. + +Extend `FreshAgentSendSchema`, `FreshAgentRuntimeAdapter.send`, `FreshAgentRuntimeManager.send`, and `server/ws-handler.ts` so turn-time runtime settings and typed image inputs cross the browser/server boundary. Import `FreshAgentInputImageSchema` and `FreshAgentRuntimeSettingsSchema` from `shared/fresh-agent-contract.ts`; do not duplicate those schemas in WebSocket protocol code: + +```ts +export const FreshAgentSendSchema = z.object({ + type: z.literal('freshAgent.send'), + sessionId: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + text: z.string().optional(), + images: z.array(FreshAgentInputImageSchema).optional(), + runtimeSettings: FreshAgentRuntimeSettingsSchema.optional(), +}).refine((value) => Boolean(value.text?.trim() || value.images?.length), { + message: 'Fresh-agent send requires text or an image', +}).superRefine((value, ctx) => { + validateFreshAgentRuntimeSettingsForProvider(value.provider, value.runtimeSettings, ctx) +}) +``` + +Apply the same locator rule to `freshAgent.interrupt`, `freshAgent.fork`, approval/request response, review start, and any future fresh-agent action message: each action schema must include `sessionType` and `provider` alongside `sessionId`, and `server/ws-handler.ts` must pass the full locator to the runtime manager. Tests should send two attached records with the same `sessionId` and different providers, then prove each action reaches only the intended adapter. + +Map input content explicitly. The Freshcodex composer/controller should pass image attachments as typed `FreshAgentInputImage` values; the adapter should convert remote URLs to Codex `{ type: 'image', url }`, convert `{ kind: 'data', mediaType, data }` into a valid `data:${mediaType};base64,${data}` URL before sending `{ type: 'image', url }`, and convert local file paths to `{ type: 'localImage', path }`. Existing Codex transcripts may contain `{ type: 'skill' }` and `{ type: 'mention' }` content parts; preserve them in normalized message content. If a new outbound input part cannot be represented by the generated schema, return a typed unsupported-capability error before starting the turn. + +Extend `FreshAgentAttachSchema`, `FreshAgentRuntimeManager.attach`, and `FreshAgentRuntimeAdapter.attach?` so a saved Freshcodex pane can provide `cwd`, `model`, `sandbox`, `permissionMode`, and `effort` when it reattaches: + +```ts +export const FreshAgentAttachSchema = z.object({ + type: z.literal('freshAgent.attach'), + sessionId: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + resumeSessionId: z.string().optional(), + cwd: z.string().optional(), + runtimeSettings: FreshAgentRuntimeSettingsSchema.optional(), +}).superRefine((value, ctx) => { + validateFreshAgentRuntimeSettingsForProvider(value.provider, value.runtimeSettings, ctx) +}) +``` + +The Codex adapter must implement `ensureThreadLoaded(sessionId, context)` and call it before snapshot, subscribe, send, interrupt, fork, and start-review work. It should first try `thread/read { includeTurns: false }`; if the returned status is `{ type: 'notLoaded' }` or the app-server reports a lost/unloaded thread, call `thread/resume` with the attach/create context and `excludeTurns: true`, then re-read metadata. If the thread still cannot be loaded, surface `FRESH_AGENT_LOST_SESSION` or `FRESH_AGENT_RUNTIME_UNAVAILABLE` with a clear pane error. This is required because a fresh stdio app-server process does not necessarily have browser-restored thread ids loaded in memory, and omitting `excludeTurns: true` would let normal restore paths load an entire long transcript before the page-first `thread/turns/list` request. + +Convert `thread/fork -> { thread, ...metadata }` to the fresh-agent fork result at the adapter boundary: + +```ts +const forked = await runtime.forkThread({ threadId: sessionId, excludeTurns: true }) +return { + sessionId: forked.thread.id, + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + parentThreadId: sessionId, + extensions: { codex: { fork: { parentThreadId: sessionId } } }, +} +``` + +Implement `getTurnBody` as a fresh-agent compatibility facade, not a Codex RPC method: + +```ts +async getTurnBody(thread, revision) { + const currentRevision = getNormalizedRevisionFor(thread.threadId) + if (revision !== currentRevision) throw new FreshAgentStaleThreadRevisionError(currentRevision) + const cached = turnBodyCache.get(`${thread.threadId}:${thread.turnId}`) + if (!cached) throw new FreshAgentTurnBodyNotLoadedError(thread.threadId, thread.turnId) + return cached +} +``` + +If a later generated schema adds a direct turn-read method, replace this cache facade in a focused follow-up. Do not add a nonexistent `thread/turn/read` call, and do not use `thread/read { includeTurns: true }` as a body-fetch fallback for long transcripts. + +Extend WS protocol with `freshAgent.forked`: + +```ts +| { + type: 'freshAgent.forked' + sourceSessionId: string + sourceSessionType: string + sourceProvider: string + sessionId: string + sessionType: string + provider: string + runtimeProvider?: string + parentThreadId?: string + } +``` + +Send it from `server/ws-handler.ts` after `freshAgent.fork`. + +Extend client-to-server WS protocol with `freshAgent.review.start` and route it through `FreshAgentRuntimeManager.startReview(locator, { target, delivery })`: + +```ts +export const FreshAgentReviewStartSchema = z.object({ + type: z.literal('freshAgent.review.start'), + sessionId: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + target: FreshAgentReviewTargetSchema.default({ type: 'uncommittedChanges' }), + delivery: z.enum(['inline', 'detached']).default('inline'), +}) + +export const FreshAgentServerRequestRespondSchema = z.object({ + type: z.literal('freshAgent.serverRequest.respond'), + sessionId: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + response: FreshAgentServerRequestResponseSchema, +}) + +export type FreshAgentServerMessage = + | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown } + | { type: 'freshAgent.review.started'; sessionId: string; sessionType: string; provider: string; turnId: string; reviewThreadId: string; target: FreshAgentReviewTarget; delivery: 'inline' | 'detached' } + | { type: 'freshAgent.killed'; sessionId: string; sessionType: string; provider: string; success: boolean } + | { type: 'freshAgent.error'; sessionId?: string; sessionType?: string; provider?: string; requestId?: string | number; code: string; message: string; retryable?: boolean } +``` + +On success, emit `freshAgent.review.started` with the returned `reviewThreadId` and full `{ sessionType, provider, sessionId }` locator, then emit a `freshAgent.event` invalidation for the same session so the workspace panel refreshes review output. On failure, emit `freshAgent.error` with a typed code and locator fields whenever the failure is session-specific. Preserve `reviewThreadId` in the Codex extension or review metadata when the snapshot refresh observes review items; do not collapse detached and inline reviews into only the source thread id. + +Extend the fresh-agent adapter/runtime contract with implemented Codex methods that Task 4 classified as supported: + +```ts +startReview?(sessionId: string, input?: { target?: FreshAgentReviewTarget; delivery?: 'inline' | 'detached' }): Promise<{ turnId: string; reviewThreadId: string; target: FreshAgentReviewTarget; delivery: 'inline' | 'detached' }> +respondToServerRequest?(sessionId: string, response: FreshAgentServerRequestResponse): Promise<void> +listThreads?(query: { limit?: number; cursor?: string; sortDirection?: 'asc' | 'desc'; sourceKinds?: string[] }): Promise<FreshAgentThreadListPage> +listLoadedThreadIds?(query?: { limit?: number; cursor?: string }): Promise<{ ids: string[]; nextCursor: string | null }> +listModels?(query?: FreshAgentModelListQuery): Promise<FreshAgentModelListPage> +readModelProviderCapabilities?(): Promise<FreshAgentModelProviderCapabilities> +``` + +Do not leave these as raw `CodexAppServerClient` helpers only. If the shared UI does not expose a method in this plan, move it from the implemented set to the explicit unsupported set in Task 4. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \ + test/unit/server/fresh-agent/codex-normalize.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Ensure `server/fresh-agent/adapters/codex/normalize.ts` contains no `Array<Record<string, unknown>>` transcript items and no unchecked `any` payload crossing into contract output. + +Run: + +```bash +rg -n "Array<Record<string, unknown>>|Promise<Record<string, any>>|turns: input\\.transcript\\.turns|extensions = .*\\?\\? \\{\\}" server/fresh-agent/adapters/codex server/coding-cli/codex-app-server +npm run test:vitest -- \ + test/unit/server/fresh-agent/codex-normalize.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/shared/fresh-agent-contract.test.ts +npm run typecheck:server +``` + +Expected: `rg` finds no stale raw transcript patterns; tests and typecheck pass. + +- [ ] **Step 6: Commit** + +```bash +git add \ + server/coding-cli/codex-app-server/rich-runtime.ts \ + server/fresh-agent/adapters/codex/normalize.ts \ + server/fresh-agent/adapters/codex/adapter.ts \ + server/fresh-agent/runtime-adapter.ts server/fresh-agent/runtime-manager.ts \ + shared/ws-protocol.ts server/ws-handler.ts src/lib/fresh-agent-ws.ts \ + src/store/paneTypes.ts \ + test/fixtures/fresh-agent/codex/contract-fixtures.ts \ + test/fixtures/coding-cli/codex-app-server/schema-traceability.ts \ + test/unit/server/fresh-agent/codex-normalize.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts +git commit -m "Normalize Codex fresh-agent turns and actions" +``` + +### Task 6: Split FreshAgentView Into Controller And Pure Shell + +**Files:** +- Create: `src/components/fresh-agent/useFreshAgentThreadController.ts` +- Create: `src/components/fresh-agent/FreshAgentShell.tsx` +- Create: `src/components/fresh-agent/fresh-agent-policy.ts` +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Modify: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` +- Modify: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` +- Modify: `src/store/paneTypes.ts` +- Modify: `src/store/panesSlice.ts` +- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Test: `test/unit/client/store/panesSlice.test.ts` + +- [ ] **Step 1: Write failing controller and shell tests** + +Tests must prove: + +```ts +it('renders freshcodex without agentChat state', async () => { + const store = configureStore({ reducer: { panes, settings, freshAgent } }) + render(<FreshAgentView ...freshcodexPane />) + expect(await screen.findByText('Codex summary')).toBeInTheDocument() +}) + +it('does not clobber newer pane fields when freshAgent.created arrives late', async () => { + // Start with model/initialCwd/user title mutated after create was sent. + // Deliver freshAgent.created. + // Assert only sessionId, resumeSessionId, status, and createError changed. +}) + +it('opens a forked freshcodex thread in a sibling pane', async () => { + emitWs({ + type: 'freshAgent.forked', + sourceSessionId: 'thread-1', + sourceSessionType: 'freshcodex', + sourceProvider: 'codex', + sessionId: 'thread-fork-1', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + parentThreadId: 'thread-1', + }) + expect(selectLayoutLeaves(store.getState(), 'tab-1')).toContainEqual(expect.objectContaining({ + content: expect.objectContaining({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-fork-1' }), + })) +}) + +it('sends Freshcodex text, images, and runtime settings without reading Claude state', async () => { + renderFreshcodexPane({ + paneContent: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', + }, + }) + await user.type(screen.getByRole('textbox', { name: /chat message input/i }), 'Use this mockup') + await attachImageUrl('https://example.test/mockup.png') + await user.click(screen.getByRole('button', { name: 'Send' })) + expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.send', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Use this mockup', + images: [{ kind: 'url', url: 'https://example.test/mockup.png', mediaType: 'image/png' }], + runtimeSettings: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', + }, + })) +}) + +it('attaches a restored Freshcodex pane with runtime context so the server can load the thread', async () => { + renderFreshcodexPane({ + paneContent: { + sessionId: 'thread-restored-1', + resumeSessionId: 'thread-restored-1', + initialCwd: '/repo', + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', + }, + }) + expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.attach', + sessionId: 'thread-restored-1', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/repo', + runtimeSettings: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', + }, + })) +}) + +it('accepts pasted or uploaded browser images as data image inputs', async () => { + renderFreshcodexPane() + await uploadImageFile(new File([new Uint8Array([1, 2, 3])], 'mockup.png', { type: 'image/png' })) + await user.type(screen.getByRole('textbox', { name: /chat message input/i }), 'Use this uploaded image') + await user.click(screen.getByRole('button', { name: 'Send' })) + expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.send', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Use this uploaded image', + images: [expect.objectContaining({ kind: 'data', mediaType: 'image/png', data: expect.any(String) })], + })) +}) + +it('responds to Codex request-user-input prompts with generated answer arrays', async () => { + renderFreshcodexPane({ snapshot: snapshotWithToolUserInputRequest }) + await user.click(screen.getByRole('button', { name: /answer a/i })) + expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.serverRequest.respond', + sessionType: 'freshcodex', + provider: 'codex', + response: { + requestId: 'user-input-1', + kind: 'tool_user_input', + answers: { choice: { answers: ['a'] } }, + }, + })) +}) + +it('responds to MCP elicitations with generated action/content metadata', async () => { + renderFreshcodexPane({ snapshot: snapshotWithMcpElicitationRequest }) + await user.click(screen.getByRole('button', { name: /accept mcp input/i })) + expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.serverRequest.respond', + sessionType: 'freshcodex', + provider: 'codex', + response: { + requestId: 'mcp-elicit-1', + kind: 'mcp_elicitation', + action: 'accept', + content: expect.any(Object), + _meta: null, + }, + })) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +``` + +Expected: FAIL because the controller/shell split does not exist and FreshAgentView imports Claude state directly. + +- [ ] **Step 3: Implement controller and shell** + +`FreshAgentView.tsx` becomes a small wrapper: + +```tsx +export function FreshAgentView(props: FreshAgentViewProps) { + const controller = useFreshAgentThreadController(props) + return <FreshAgentShell {...controller.shellProps} /> +} +``` + +`useFreshAgentThreadController.ts` owns: + +- create/attach WS sends +- snapshot loading +- turn page/body loading hooks needed by Task 7 +- image attachment state passed from the composer to `freshAgent.send` +- retry/recovery state +- action dispatchers +- forked-pane creation through `splitPane` +- controlled load/create/action errors +- attach context for restored Freshcodex panes, including cwd and Codex runtime settings, so the server-side adapter can call `thread/resume` before snapshot/action work when a new app-server process has not loaded the thread + +`FreshAgentShell.tsx` is pure and receives typed props: + +```ts +type FreshAgentShellProps = { + descriptorLabel: string + statusLabel: string + summaryText: string + snapshot: FreshAgentThreadSnapshot | null + loadError: string | null + createError: FreshAgentCreateError | null + actions: { + send(text: string, images?: FreshAgentInputImage[], runtimeSettings?: FreshAgentRuntimeSettings): void + interrupt(): void + fork(): void + startReview(target?: FreshAgentReviewTarget, delivery?: 'inline' | 'detached'): void + retryCreate(): void + respondToServerRequest(response: FreshAgentServerRequestResponse): void + } +} +``` + +`FreshAgentRuntimeSettings` is a shared client/server shape for turn-time overrides: + +```ts +type FreshAgentRuntimeSettings = { + model?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + permissionMode?: CodexApprovalPolicy | ClaudePermissionMode + effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' +} +``` + +Provider policy helpers must validate these union fields with the provider-specific schemas before action dispatch. Freshcodex may dispatch only generated Codex `approvalPolicy` and `effort` values; Freshclaude may keep its existing Claude-specific permission/effort values. The shell should show a controlled settings error if a migrated Freshcodex pane still contains Claude-only values, and the WebSocket/client action parser must reject those values before the runtime manager calls the Codex adapter. + +`FreshAgentComposer.tsx` must support browser-representable image input directly, not only a test helper. Add an accessible image URL attachment control and file/paste handling that converts selected browser files to `{ kind: 'data', mediaType, data }` before dispatch. Keep `{ kind: 'local', path }` in the shared contract for server-side or restored Codex content, but do not pretend the browser can produce arbitrary local filesystem paths without an explicit server-side file picker. + +`fresh-agent-policy.ts` owns small pure helpers: + +```ts +export function getFreshAgentStatusLabel(status: FreshAgentPaneStatus, readModelStatus?: FreshAgentThreadStatus, restoring?: boolean): string +export function getFreshAgentQuestionLabel(sessionType: FreshAgentSessionType, provider: FreshAgentRuntimeProvider): string +export function canUseFreshAgentAction(snapshot: FreshAgentThreadSnapshot | null, action: keyof FreshAgentCapabilities): boolean +export function usesClaudeRestoreState(sessionType: FreshAgentSessionType, provider: FreshAgentRuntimeProvider): boolean +``` + +Use `mergePaneContent` for async field updates: + +```ts +dispatch(mergePaneContent({ + tabId, + paneId, + updates: { + sessionId: message.sessionId, + resumeSessionId: paneContentRef.current.resumeSessionId ?? message.sessionId, + status: 'connected', + createError: undefined, + }, +})) +``` + +When attaching a pane that already has `sessionId`, send: + +```ts +ws.send({ + type: 'freshAgent.attach', + sessionId, + sessionType, + provider, + resumeSessionId: paneContent.resumeSessionId, + cwd: paneContent.initialCwd, + runtimeSettings: { + model: paneContent.model, + sandbox: paneContent.sandbox, + permissionMode: paneContent.permissionMode, + effort: paneContent.effort, + }, +}) +``` + +Do not reduce attach to `{ sessionId, sessionType }` for Freshcodex; that loses the context needed to load the thread into a fresh stdio app-server runtime. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/store/panesSlice.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Remove local duplicated snapshot/item types from `FreshAgentView.tsx`; import all fresh-agent read-model types from `shared/fresh-agent-contract`. + +Run: + +```bash +rg -n "type FreshAgentSnapshot|state\\.agentChat|\\.\\.\\.paneContent" src/components/fresh-agent +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/components/panes/PaneContainer.test.tsx +npm run typecheck:client +``` + +Expected: `rg` finds no stale local snapshot type and no Claude state read outside policy/controller; tests and typecheck pass. A controlled `paneContentRef.current` spread for explicit retry replacement is acceptable, but async message handlers should not spread captured `paneContent`. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/components/fresh-agent/useFreshAgentThreadController.ts \ + src/components/fresh-agent/FreshAgentShell.tsx \ + src/components/fresh-agent/fresh-agent-policy.ts \ + src/components/fresh-agent/FreshAgentView.tsx \ + src/components/fresh-agent/FreshAgentComposer.tsx \ + src/components/fresh-agent/FreshAgentApprovalBanner.tsx \ + src/components/fresh-agent/FreshAgentQuestionBanner.tsx \ + src/store/paneTypes.ts \ + src/store/panesSlice.ts \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/store/panesSlice.test.ts +git commit -m "Split fresh-agent controller from shell" +``` + +### Task 7: Add Turn Paging, Body Hydration, And Transcript Virtualization + +**Files:** +- Create: `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx` +- Modify: `src/components/fresh-agent/FreshAgentTranscript.tsx` +- Modify: `src/components/fresh-agent/useFreshAgentThreadController.ts` +- Modify: `src/components/fresh-agent/FreshAgentShell.tsx` +- Modify: `src/lib/api.ts` +- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx` +- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` +- Test: `test/unit/client/lib/api.fresh-agent-contract.test.ts` + +- [ ] **Step 1: Write failing paging and virtualization tests** + +Add tests: + +```ts +it('loads a visible turn page and hydrates missing bodies on demand', async () => { + api.getFreshAgentTurnPage.mockResolvedValue(contractPageWithTwoSummaries) + api.getFreshAgentTurnBody.mockResolvedValue(contractBodyForTurn2) + renderFreshcodex() + expect(await screen.findByText('Preview for turn 1')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /load full turn 2/i })) + expect(await screen.findByText('Full body for turn 2')).toBeInTheDocument() +}) + +it('does not render every row in a 1000-turn transcript', () => { + render(<FreshAgentTranscriptVirtualList turns={makeTurns(1000)} ... />) + expect(screen.queryByText('turn 999')).not.toBeInTheDocument() +}) + +it('shows a stale revision error instead of mixing page and body revisions', async () => { + api.getFreshAgentTurnBody.mockRejectedValue({ code: 'STALE_THREAD_REVISION', currentRevision: 9 }) + expect(await screen.findByText(/session changed while loading/i)).toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +``` + +Expected: FAIL because transcript paging and virtualization are not implemented. + +- [ ] **Step 3: Implement virtualized transcript state** + +Use `react-window` already present in the repo. This repo has `react-window@2.x`, which exports `List`, not the old v1 `FixedSizeList`. The controller should: + +- Use `snapshot.initialTurnPage` as the initial visible page when present. Snapshots should not expose or require a full `turns` array. +- If `snapshot.capabilities.turnPaging`, call `getFreshAgentTurnPage(sessionType, provider, threadId, { revision, priority: 'visible', limit, sortDirection })`; the server adapter maps this to Codex `thread/turns/list` without sending unsupported `revision` or `includeBodies` fields to app-server. +- Store turn summaries keyed by `turnId`; when a page summary includes `body`, render that body directly and cache it client-side. +- Hydrate body through `getFreshAgentTurnBody` only for providers or summaries that advertise an uncached body endpoint. For Codex, page results already contain the app-server `Turn` items, so the normal hydration path is loading the containing page, not calling a direct body endpoint and not a nonexistent Codex `thread/turn/read` method. +- Refresh the snapshot and first page on stale revision errors. + +`FreshAgentTranscriptVirtualList.tsx` should render: + +```tsx +import { List } from 'react-window' + +function Row({ + ariaAttributes, + index, + style, + turns, + hydrateTurn, +}: { + ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' } + index: number + style: React.CSSProperties + turns: FreshAgentTurnSummary[] + hydrateTurn: (turnId: string) => void +}) { + const turn = turns[index] + return ( + <div {...ariaAttributes} style={style}> + <FreshAgentTurnRow turn={turn} onHydrate={hydrateTurn} /> + </div> + ) +} + +<List + className="min-h-0 flex-1" + defaultHeight={availableHeight} + rowComponent={Row} + rowCount={turns.length} + rowHeight={estimatedTurnHeight} + rowProps={{ turns, hydrateTurn }} + overscanCount={4} + style={{ height: availableHeight, width: '100%' }} +/> +``` + +Keep accessible markup inside each row: role/heading labels must remain visible to browser-use automation. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Ensure the empty transcript state still renders when snapshot and page are empty. + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/components/HistoryView.mobile.test.tsx +npm run typecheck:client +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx \ + src/components/fresh-agent/FreshAgentTranscript.tsx \ + src/components/fresh-agent/useFreshAgentThreadController.ts \ + src/components/fresh-agent/FreshAgentShell.tsx \ + src/lib/api.ts \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx \ + test/unit/client/lib/api.fresh-agent-contract.test.ts +git commit -m "Page and virtualize fresh-agent transcripts" +``` + +### Task 8: Build Freshcodex Item, Diff, Review, Worktree, And Fork UX + +**Files:** +- Create: `src/components/fresh-agent/FreshAgentItemCard.tsx` +- Create: `src/components/fresh-agent/FreshAgentWorkspacePanel.tsx` +- Modify: `src/components/fresh-agent/FreshAgentTranscript.tsx` +- Modify: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- Modify: `src/components/fresh-agent/FreshAgentSidebar.tsx` +- Modify: `src/components/fresh-agent/FreshAgentShell.tsx` +- Modify: `src/components/agent-chat/DiffView.tsx` only if a shared prop is needed +- Test: `test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Test: `test/e2e-browser/specs/fresh-agent.spec.ts` + +- [ ] **Step 1: Write failing UX tests** + +Use typed fixtures to assert every important Codex surface: + +```ts +expect(screen.getByRole('article', { name: /command npm test/i })).toHaveTextContent('completed') +expect(screen.getByRole('button', { name: /view diff src\/app.ts/i })).toBeInTheDocument() +expect(screen.getByRole('article', { name: /renamed file src\/new-name.ts/i })).toHaveTextContent('src/old-name.ts') +expect(screen.getByRole('region', { name: /review current changes/i })).toHaveTextContent('No blocking findings') +await user.click(screen.getByRole('button', { name: /start codex review/i })) +expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.review.start', + sessionId: 'thread-1', +})) +expect(screen.getByRole('region', { name: /worktree/i })).toHaveTextContent('feature/freshcodex') +expect(screen.getByRole('region', { name: /fork lineage/i })).toHaveTextContent('thread-parent-1') +expect(screen.getByRole('region', { name: /child threads/i })).toHaveTextContent('Review shell') +expect(screen.getByRole('article', { name: /child-agent action spawnAgent/i })).toHaveTextContent('thread-child-1') +expect(screen.getByRole('article', { name: /child-agent action spawnAgent/i })).toHaveTextContent('thread-child-2') +expect(screen.getByRole('button', { name: /mentioned file README.md/i })).toHaveTextContent('/repo/README.md') +expect(screen.getByRole('button', { name: /skill reviewer/i })).toHaveTextContent('reviewer') +``` + +Browser e2e should seed a Freshcodex snapshot with a file-change diff and verify the diff is expandable without relying on selectors. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +``` + +Expected: FAIL because normalized Codex item rendering and workspace panel are incomplete. + +- [ ] **Step 3: Implement item and workspace UI** + +`FreshAgentItemCard.tsx` renders one contract item with semantic labels: + +- `message`: role-labelled user/assistant/system message with ordered text/image/mention/skill parts, preserving generated text elements, assistant message phase, and memory citations. +- `message` mention/skill parts: render preserved Codex `mention` and `skill` content parts as accessible inline chips with their names and paths. +- `text`: markdown/plain text with wrapping. +- `hook_prompt`: hook/context prompt fragments with generated `hookRunId` available for accessible details without exposing raw JSON. +- `reasoning`: collapsed by default, with generated summary visible, full generated content available behind an accessible toggle, and no loss of either array. +- `plan`: plan card. +- `command`: command, cwd, source, status, command-action metadata, output, exit code, process id, and duration. +- `file_change`: list changed files, distinguish add/modify/delete/rename, show `movePath` for renamed/moved files, and provide expandable diffs using shared `DiffView`. +- `tool`: MCP/tool card with server, arbitrary JSON input, MCP app resource URI, structured result metadata, error, and duration. +- `dynamic_tool`: unsupported or completed dynamic tool call state with namespace, arbitrary JSON input, generated output content items, success state, duration, and the user-visible response. +- `collaboration`: child-agent action card with all `receiverThreadIds`, sender id, prompt, model/effort metadata, and generated agent-state summaries when available. +- `review`: entered/exited review cards. +- `web_search`: query, structured generated action (`search`, `openPage`, `findInPage`, or `other`), and status. +- `image`: path/url card. +- `image_generation`: prompt, raw generated status string, raw generated `result`, optional `savedPath`, optional derived display status, and generated image metadata when available. +- `context_compaction`: compaction status and token before/after summary when available. +- `request_prompt`: pending/resolved approval/question/tool prompt state. +- `error`: alert card. + +`FreshAgentWorkspacePanel.tsx` replaces sidebar-only listing for: + +- worktrees +- child threads +- diffs +- review metadata and review output +- start-review action when `snapshot.capabilities.review` or the Codex extension says review is supported; disabled with a clear label otherwise +- fork lineage +- token/context details + +Keep a compact sidebar on narrow panes and full details in the main panel. Use semantic `button`, `section`, `article`, headings, and `aria-label`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run the browser spec after unit tests: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts +npm run typecheck:client +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/components/fresh-agent/FreshAgentItemCard.tsx \ + src/components/fresh-agent/FreshAgentWorkspacePanel.tsx \ + src/components/fresh-agent/FreshAgentTranscript.tsx \ + src/components/fresh-agent/FreshAgentDiffPanel.tsx \ + src/components/fresh-agent/FreshAgentSidebar.tsx \ + src/components/fresh-agent/FreshAgentShell.tsx \ + src/components/agent-chat/DiffView.tsx \ + test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/e2e-browser/specs/fresh-agent.spec.ts +git commit -m "Render rich Freshcodex transcript and workspace items" +``` + +### Task 9: Port Mobile Keyboard, Touch, And Performance Fixes Into Fresh-Agent + +**Files:** +- Modify: `src/components/fresh-agent/FreshAgentShell.tsx` +- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Modify: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` +- Modify: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` +- Modify: `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx` +- Modify: `src/hooks/useKeyboardInset.ts` +- Modify: `test/e2e-browser/perf/scenarios.ts` +- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx` if it does not already exist, create it +- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx` +- Test: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` + +- [ ] **Step 1: Write failing mobile tests** + +Add tests proving: + +```ts +expect(screen.getByRole('textbox', { name: /chat message input/i })).toHaveAttribute('enterkeyhint', 'send') +expect(screen.getByRole('button', { name: 'Send' })).toHaveClass(expect.stringMatching(/min-h|h-/)) +expect(screen.getByTestId('fresh-agent-root')).toHaveStyle({ paddingBottom: 'var(--keyboard-inset-bottom)' }) +``` + +Browser mobile spec should verify the composer remains visible while typing and approval/question buttons have accessible labels and usable touch size. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx +``` + +Expected: FAIL because FreshAgent shell has not yet ported the main-branch mobile keyboard behavior. + +- [ ] **Step 3: Implement mobile behavior** + +Port the main `agent-chat` keyboard/touch behavior into fresh-agent components without importing `agent-chat` view state: + +- apply `useKeyboardInset` to the fresh-agent root/composer region +- keep composer sticky in mobile panes +- preserve virtualization container height when keyboard inset changes +- ensure approval/question/action buttons have accessible names and mobile touch targets +- keep transcript scroll stable on send and on snapshot refresh + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/unit/client/hooks/useKeyboardInset.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent-mobile.spec.ts +npm run test:visible-first:contract +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/components/fresh-agent/FreshAgentShell.tsx \ + src/components/fresh-agent/FreshAgentComposer.tsx \ + src/components/fresh-agent/FreshAgentApprovalBanner.tsx \ + src/components/fresh-agent/FreshAgentQuestionBanner.tsx \ + src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx \ + src/hooks/useKeyboardInset.ts test/e2e-browser/perf/scenarios.ts \ + test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \ + test/e2e-browser/specs/fresh-agent-mobile.spec.ts +git commit -m "Port mobile ergonomics to fresh-agent shell" +``` + +### Task 10: Finish Freshcodex Session Identity, Titles, Sidebar, And Settings + +**Files:** +- Modify: `src/lib/fresh-agent-registry.ts` +- Modify: `src/lib/derivePaneTitle.ts` +- Modify: `src/lib/session-utils.ts` +- Modify: `src/lib/session-type-utils.ts` +- Modify: `src/lib/tab-registry-snapshot.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/pane-activity.ts` +- Modify: `src/store/tabsSlice.ts` +- Modify: `src/store/paneTreeValidation.ts` +- Modify: `src/store/panesSlice.ts` +- Modify: `src/store/persistedState.ts` if runtime-setting persistence schemas need versioned validation changes +- Modify: `src/store/managed-items.ts` +- Modify: `src/store/settingsThunks.ts` +- Modify: `src/store/selectors/sidebarSelectors.ts` +- Modify: `src/components/ExtensionsView.tsx` +- Modify: `src/components/HistoryView.tsx` +- Modify: `src/components/TabsView.tsx` +- Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/panes/PanePicker.tsx` +- Modify: `src/components/SettingsView.tsx` +- Modify: `src/store/paneTypes.ts` +- Modify: `server/fresh-agent/runtime-manager.ts` +- Modify: `server/fresh-agent/router.ts` +- Modify: `server/session-directory/projection.ts` +- Modify: `server/coding-cli/session-indexer.ts` +- Modify: `shared/settings.ts` +- Test: `test/unit/shared/fresh-agent-registry.test.ts` +- Test: `test/unit/client/lib/derivePaneTitle.test.ts` +- Test: `test/unit/client/lib/session-utils.test.ts` +- Test: `test/unit/client/lib/session-type-utils.test.ts` +- Test: `test/unit/client/lib/tab-registry-snapshot.test.ts` +- Test: `test/unit/client/lib/pane-activity.test.ts` +- Test: `test/unit/client/store/selectors/sidebarSelectors.test.ts` +- Test: `test/unit/client/store/panesPersistence.test.ts` +- Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts` +- Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` +- Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx` +- Test: `test/unit/client/components/ExtensionsView.test.tsx` +- Test: `test/unit/client/components/HistoryView.mobile.test.tsx` +- Test: `test/unit/client/components/panes/PaneContainer.test.tsx` +- Test: `test/unit/client/components/panes/PanePicker.test.tsx` +- Test: `test/unit/server/fresh-agent/router.test.ts` +- Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` +- Test: `test/unit/server/coding-cli/session-indexer.test.ts` + +- [ ] **Step 1: Write failing identity tests** + +Add tests: + +```ts +expect(derivePaneTitle({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', createRequestId: 'r', status: 'idle' })) + .toBe('Freshcodex') + +expect(collectSessionRefsFromNode(freshcodexLayout)).toContainEqual(expect.objectContaining({ + provider: 'codex', + sessionType: 'freshcodex', +})) + +expect(projectFreshAgentSession(codexThread)).toMatchObject({ + provider: 'codex', + sessionType: 'freshcodex', + title: expect.any(String), +}) + +expect(projectFreshAgentSession(schemaValidThread({ + id: 'thread-child-1', + source: { subAgent: { thread_spawn: { + parent_thread_id: 'thread-parent-1', + depth: 1, + agent_path: null, + agent_nickname: 'reviewer', + agent_role: 'review', + } } }, + turns: [], +}))).toMatchObject({ + provider: 'codex', + sessionType: 'freshcodex', + sessionId: 'thread-child-1', + parentThreadId: 'thread-parent-1', + source: expect.objectContaining({ + subAgent: expect.objectContaining({ + thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }), + }), + }), +}) + +runtime.listThreads.mockResolvedValue({ data: [codexThread], nextCursor: null, backwardsCursor: null }) +await expect(loadFreshcodexHistoryPage({ limit: 25 })).resolves.toMatchObject({ + items: [expect.objectContaining({ provider: 'codex', sessionType: 'freshcodex', sessionId: codexThread.id })], + nextCursor: null, + backwardsCursor: null, +}) +expect(runtime.listThreads).toHaveBeenCalledWith(expect.objectContaining({ + limit: 25, + sourceKinds: ['cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'], +})) + +runtime.listModels.mockResolvedValue({ + data: [{ + id: 'fixture-model', + model: 'fixture-model', + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: 'Fixture Model', + description: 'Fixture model for tests', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: 'medium', + inputModalities: [], + supportsPersonality: false, + additionalSpeedTiers: [], + isDefault: true, + }], + nextCursor: null, +}) +runtime.readModelProviderCapabilities.mockResolvedValue({ namespaceTools: true, imageGeneration: false, webSearch: true }) +await expect(loadFreshcodexModelPage({ limit: 25 })).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 'fixture-model' })], + nextCursor: null, + providerCapabilities: { namespaceTools: true, imageGeneration: false, webSearch: true }, +}) +await expect(loadFreshcodexModelOptions()).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'fixture-model' }), +])) +await expect(loadFreshcodexModelProviderCapabilities()).resolves.toEqual({ + namespaceTools: true, + imageGeneration: false, + webSearch: true, +}) + +expect(createFreshcodexPaneFromSettings({ + codingCli: { + providers: { + codex: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + }, + }, + }, + freshAgent: { + providers: { + freshcodex: { + defaultEffort: 'xhigh', + }, + }, + }, +})).toMatchObject({ + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', +}) + +expect(createFreshcodexPaneFromSettings({ + codingCli: { + providers: { + codex: { + model: 'configured-model', + sandbox: 'workspace-write', + permissionMode: 'bypassPermissions', + }, + }, + }, + freshAgent: { + providers: { + freshcodex: { + defaultEffort: 'max', + }, + }, + }, +})).toMatchObject({ + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + createError: expect.objectContaining({ code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' }), +}) +``` + +Also test that `buildResumeContent`, `TabsView` remote snapshot hydration, `collectPaneSnapshots`, `paneTreeValidation`, and pane persistence preserve Freshcodex `sandbox`, generated Codex effort values, and generated Codex approval policies without narrowing them to Claude strings: + +```ts +expect(buildResumeContent({ + sessionType: 'freshcodex', + sessionId: 'thread-1', + cwd: '/repo', + freshAgentProviderSettings: { + defaultModel: 'configured-model', + defaultSandbox: 'workspace-write', + defaultPermissionMode: 'on-request', + defaultEffort: 'xhigh', + }, +})).toMatchObject({ + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + sandbox: 'workspace-write', + permissionMode: 'on-request', + effort: 'xhigh', +}) + +expect(collectPaneSnapshots({ + type: 'leaf', + id: 'pane-1', + content: { + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + resumeSessionId: 'thread-1', + createRequestId: 'req-1', + status: 'idle', + sandbox: 'workspace-write', + permissionMode: { granular: { sandbox_approval: true, rules: true, skill_approval: true, request_permissions: true, mcp_elicitations: true } }, + effort: 'xhigh', + }, +}, 'server-1')).toContainEqual(expect.objectContaining({ + payload: expect.objectContaining({ + sandbox: 'workspace-write', + permissionMode: expect.objectContaining({ granular: expect.any(Object) }), + effort: 'xhigh', + }), +})) +``` + +Add persistence tests for browser-reloaded Freshcodex panes with valid Codex runtime settings and for legacy Freshcodex panes with Claude-only values. Valid panes must rehydrate unchanged and attach; legacy invalid panes must remain visible with a controlled `createError`, not be dropped, silently coerced, or replaced with a picker pane. Also test that `freshcodex` settings appear independently from Freshclaude where the UI exposes runtime settings, and `freshopencode` remains disabled/hidden. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/shared/fresh-agent-registry.test.ts \ + test/unit/client/lib/derivePaneTitle.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/storage-migration.fresh-agent.test.ts \ + test/unit/client/store/persisted-state.fresh-agent.test.ts \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/ExtensionsView.test.tsx \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/server/session-directory/fresh-agent-projection.test.ts +``` + +Expected: FAIL for any identity/title/sidebar/settings/history/snapshot gaps still coupled to `agent-chat` or Claude-shaped runtime assumptions. + +- [ ] **Step 3: Implement identity fixes** + +Rules: + +- `freshcodex` title defaults to `Freshcodex`, then updates from the first user message or thread name when available. +- `provider: 'codex'` plus `sessionType: 'freshcodex'` is the session ref identity. +- `sandbox` is stored on fresh-agent pane content and comes from Codex provider settings, not from Claude/Freshclaude settings. +- `freshcodex` default permission/effort settings must be Codex-shaped. Replace any Freshcodex registry/default value that still uses Claude-specific permission modes such as `bypassPermissions` with a generated Codex approval policy such as `on-request`; replace any Freshcodex default effort that still uses Claude-only `max` with a generated Codex effort value. Do not mutate Freshclaude or Kilroy defaults. +- Split settings types so Codex and Claude defaults cannot be accidentally interchanged. In `shared/settings.ts`, stop typing `codingCli.providers.codex.permissionMode` and `freshAgent.providers.freshcodex.defaultEffort` with Claude-only aliases. Introduce Codex-specific approval/effort/sandbox schemas based on the generated app-server values, keep Claude-specific settings for Freshclaude/Kilroy, and migrate invalid legacy Freshcodex values into a visible `createError` rather than silently coercing them. +- Update `src/lib/session-type-utils.ts`, `src/store/tabsSlice.ts`, and `src/components/panes/PaneContainer.tsx` so Freshcodex creation and resume use Freshcodex settings from `freshAgent.providers.freshcodex` plus Codex CLI defaults from `codingCli.providers.codex`. Do not route Freshcodex through `getAgentChatProviderConfig()` or an `agentChatProviderSettings` parameter; those are Claude/Kilroy compatibility paths only. +- Update `src/lib/tab-registry-snapshot.ts`, `src/components/TabsView.tsx`, `src/store/paneTreeValidation.ts`, and pane persistence schemas/tests so Freshcodex `sandbox`, generated Codex effort values, and structured/generated approval policies are preserved in local and remote tab snapshots. Remote snapshots must not cast Freshcodex effort back to `'low' | 'medium' | 'high' | 'max'`, must not cast structured approval policy objects to strings, and must not omit `sandbox`. +- Update `src/store/managed-items.ts`, `src/components/ExtensionsView.tsx`, and `src/store/settingsThunks.ts` where provider settings are exposed or sanitized so Codex provider settings do not offer or accept Claude permission modes for Freshcodex defaults. If raw Codex terminal settings still need a narrower CLI-specific representation, model that separately from Freshcodex rich runtime settings. +- Add fresh-agent REST/API surfaces for the adapter methods classified as implemented in Task 5: list Freshcodex threads, list loaded Freshcodex thread ids, list models, and read model-provider capabilities. These should be typed in `server/fresh-agent/runtime-manager.ts`, exposed by `server/fresh-agent/router.ts`, parsed in `src/lib/api.ts`, and consumed by history/settings UI. Do not leave `thread/list`, `thread/loaded/list`, `model/list`, or `modelProvider/capabilities/read` as uncalled low-level app-server helpers after classifying them as implemented. The thread-list surface must reflect the generated app-server shape after fresh-agent normalization (`{ items, nextCursor, backwardsCursor }`) rather than returning a bare array; the loaded-list surface must reflect the generated app-server shape (`{ ids, nextCursor }` after fresh-agent normalization), or explicitly hydrate those ids with `thread/read`; it must not return fake `FreshAgentSessionSummary` rows from `thread/loaded/list` alone. The model-list surface must likewise reflect the generated paginated shape after fresh-agent normalization (`{ items, nextCursor }`) rather than returning a bare first-page array. +- Feed Freshcodex history/session rows from the Codex rich adapter's `thread/list` results where available, projected through `session-directory` with `sessionType: 'freshcodex'` and `provider: 'codex'`. Existing file/indexer-derived Codex terminal history may remain for raw Codex terminal panes, but it must not be the only source for Freshcodex rich threads. The Freshcodex history query must pass explicit generated `sourceKinds` for CLI-created sessions, rich app-server sessions, command/exec sessions, locally created app-server threads reported as `vscode`, and child-agent sessions, at least `['cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther']`, rather than relying on the app-server default source filter. This keeps CLI-created Codex threads, locally created Freshcodex threads, app-server-created threads, exec sessions, review threads, compaction subagent threads, and spawned child-agent threads visible even if Codex changes the default "interactive" source set. +- Preserve generated `Thread.source` separately from the `thread/list` `sourceKinds` filter. For subagent threads, parse and store nested `SessionSource` metadata such as `{ subAgent: { thread_spawn: ... } }`; derive `parentThreadId`, child-thread labels, and fork/child UX from that nested metadata where available. Do not flatten returned `Thread.source` into the source-kind filter enum because that loses spawned-agent parent ids, depth, nickname, and role. +- Feed Freshcodex model/settings options from `model/list` plus `modelProvider/capabilities/read` and cache them behind the fresh-agent adapter boundary. `loadFreshcodexModelPage` should preserve the page cursor for settings UIs that can page model options; any `loadFreshcodexModelOptions` convenience helper that returns an array must explicitly iterate pages until `nextCursor` is null and fail on cursor cycles or an excessive page count instead of silently truncating. If the runtime is unavailable, show a typed runtime-unavailable settings error rather than falling back to stale Claude model defaults. +- Hidden `kilroy` resolves to Claude runtime metadata but does not appear as a public picker entry. +- `freshopencode` remains disabled and cannot be created. +- Settings and history labels use `sessionType`; runtime behavior uses `provider`. + +Port main's agent-chat auto-title behavior into fresh-agent by using shared title utilities, not by importing `AgentChatView`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/shared/fresh-agent-registry.test.ts \ + test/unit/client/lib/derivePaneTitle.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/storage-migration.fresh-agent.test.ts \ + test/unit/client/store/persisted-state.fresh-agent.test.ts \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/ExtensionsView.test.tsx \ + test/unit/client/components/HistoryView.mobile.test.tsx \ + test/unit/client/components/panes/PaneContainer.test.tsx \ + test/unit/client/components/panes/PanePicker.test.tsx \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/server/session-directory/fresh-agent-projection.test.ts \ + test/unit/server/coding-cli/session-indexer.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run: + +```bash +rg -n "freshcodex.*agentChat|agentChat.*freshcodex|state\\.agentChat.*freshcodex|kind: 'agent-chat'.*freshcodex" src server test +rg -n "agentChatSlice|agentChatTypes|agentChatThunks" src/store/freshAgentSlice.ts src/store/freshAgentTypes.ts src/store/freshAgentThunks.ts src/lib/pane-activity.ts +rg -n "effort.*'max'|as 'low' \\| 'medium' \\| 'high' \\| 'max'|permissionMode as string" src/lib/fresh-agent-registry.ts src/lib/session-type-utils.ts src/lib/tab-registry-snapshot.ts src/components/TabsView.tsx src/store/paneTreeValidation.ts +npm run typecheck +``` + +Expected: `rg` commands find no Freshcodex dependence on agent-chat state, no fresh-agent slice/type/thunk alias back to agent-chat modules, and no Freshcodex runtime-setting narrowing to Claude-only effort or string-only permission values; typecheck passes. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/lib/fresh-agent-registry.ts src/lib/derivePaneTitle.ts src/lib/session-utils.ts \ + src/lib/session-type-utils.ts src/lib/tab-registry-snapshot.ts src/lib/api.ts \ + src/lib/pane-activity.ts \ + src/store/selectors/sidebarSelectors.ts src/components/HistoryView.tsx \ + src/components/ExtensionsView.tsx src/components/TabsView.tsx \ + src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx \ + src/components/SettingsView.tsx src/store/paneTypes.ts src/store/panesSlice.ts \ + src/store/paneTreeValidation.ts src/store/persistedState.ts src/store/tabsSlice.ts \ + src/store/managed-items.ts src/store/settingsThunks.ts \ + server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts \ + server/session-directory/projection.ts \ + server/coding-cli/session-indexer.ts shared/settings.ts \ + test/unit/shared/fresh-agent-registry.test.ts \ + test/unit/client/lib/derivePaneTitle.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/storage-migration.fresh-agent.test.ts \ + test/unit/client/store/persisted-state.fresh-agent.test.ts \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/ExtensionsView.test.tsx \ + test/unit/client/components/HistoryView.mobile.test.tsx \ + test/unit/client/components/panes/PaneContainer.test.tsx \ + test/unit/client/components/panes/PanePicker.test.tsx \ + test/unit/server/fresh-agent/router.test.ts \ + test/unit/server/session-directory/fresh-agent-projection.test.ts \ + test/unit/server/coding-cli/session-indexer.test.ts +git commit -m "Finalize Freshcodex identity and session projections" +``` + +### Task 11: Harden Error Handling, Reconnect, And Multi-Client Freshcodex Behavior + +**Files:** +- Modify: `server/ws-handler.ts` +- Modify: `server/fresh-agent/runtime-manager.ts` +- Modify: `server/fresh-agent/adapters/codex/adapter.ts` +- Modify: `src/lib/fresh-agent-ws.ts` +- Modify: `src/components/fresh-agent/useFreshAgentThreadController.ts` +- Modify: `src/components/fresh-agent/FreshAgentShell.tsx` +- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` +- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` +- Test: `test/unit/client/lib/fresh-agent-ws.test.ts` +- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx` +- Test: `test/e2e-browser/specs/fresh-agent.spec.ts` + +- [ ] **Step 1: Write failing resilience tests** + +Add tests for: + +```ts +it('keeps two clients subscribed to the same Freshcodex thread without dropping either on event refresh', ...) +it('refreshes both clients when a Codex turn/item/token/diff notification invalidates the Freshcodex snapshot', ...) +it('emits a freshAgent.error message instead of generic sdk error for freshcodex action failures', ...) +it('recovers a stopped Codex app-server by surfacing runtime unavailable and enabling retry, not by clearing pane state', ...) +it('does not create duplicate turn starts when the browser reconnects and reattaches a pane', ...) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx +``` + +Expected: FAIL for missing typed fresh-agent action errors and duplicate/reconnect guards. + +- [ ] **Step 3: Implement resilience behavior** + +Use the fresh-agent specific error message added in Task 5 consistently in `shared/ws-protocol.ts` and handlers: + +```ts +| { type: 'freshAgent.error'; sessionId?: string; sessionType?: string; provider?: string; requestId?: string | number; code: string; message: string; retryable?: boolean } +``` + +Use this message for fresh-agent action errors instead of generic `sendError`. Include `sessionType` and `provider` whenever `sessionId` is present so client-side reconnect/error handling never has to guess which runtime owns an opaque id. + +In the controller: + +- store last create request id sent per pane +- do not re-send create for a pane with an in-flight request unless retry explicitly changes `createRequestId` +- attach on reconnect if `sessionId` exists +- refresh snapshot on `freshAgent.event`, `freshAgent.error` when recoverable, and `freshAgent.forked` +- debounce notification-driven snapshot refresh per session so a burst of Codex item/token/diff events causes one near-term refresh, not one REST request per raw app-server notification +- keep action errors in shell state until dismissed or superseded + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +npm run test:vitest -- \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add \ + shared/ws-protocol.ts server/ws-handler.ts \ + server/fresh-agent/runtime-manager.ts \ + server/fresh-agent/adapters/codex/adapter.ts \ + src/lib/fresh-agent-ws.ts \ + src/components/fresh-agent/useFreshAgentThreadController.ts \ + src/components/fresh-agent/FreshAgentShell.tsx \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \ + test/e2e-browser/specs/fresh-agent.spec.ts +git commit -m "Harden Freshcodex reconnect and action errors" +``` + +### Task 12: Documentation, Cleanup, And Final Cutover Verification + +**Files:** +- Modify: `docs/index.html` +- Modify: `docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md` only if implementation changes alter the living Freshcodex acceptance-test inventory; otherwise leave the test plan untouched +- Modify: `test/e2e-browser/specs/fresh-agent.spec.ts` +- Modify: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +- Modify: any tests renamed from legacy `agent-chat` specs only if they now cover fresh-agent behavior + +- [ ] **Step 1: Write or update final acceptance checks** + +Ensure browser specs cover: + +```ts +test('freshcodex create, send, interrupt, approval, question, fork, diff, and reconnect', ...) +test('freshcodex mobile composer remains usable with long virtualized transcript', ...) +``` + +Do not delete legacy `agent-chat` browser specs unless equivalent Freshclaude or Freshcodex coverage exists and the old UI path is truly gone. + +- [ ] **Step 2: Run targeted acceptance checks** + +Run: + +```bash +npm run test:e2e:chromium -- \ + test/e2e-browser/specs/fresh-agent.spec.ts \ + test/e2e-browser/specs/fresh-agent-mobile.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Update docs and clean stale names** + +Update `docs/index.html` to show Freshcodex as a rich fresh-agent pane with transcript, diff/review, fork, and worktree surfaces. + +Run: + +```bash +rg -n "freshcodex.*agent-chat|Freshcodex.*agent-chat|sdk\\.send.*freshcodex|kind: 'agent-chat'.*freshcodex|provider: 'freshcodex'" src server shared test docs/index.html +``` + +Expected: no stale Freshcodex-on-agent-chat references in product code, tests, shared contracts, or the public docs mock. Do not include `docs/plans/**` in this grep; the historical implementation plan itself intentionally describes stale references that the implementation is removing. Legacy Freshclaude/agent-chat references may remain where they are still intentional. + +- [ ] **Step 4: Run full verification** + +Use the coordinator gate for broad tests: + +```bash +npm run lint +npm run audit:codex-app-server-schema +npm run build +FRESHELL_TEST_SUMMARY="freshcodex contract foundation final verification" npm test +npm run test:e2e:chromium -- \ + test/e2e-browser/specs/fresh-agent.spec.ts \ + test/e2e-browser/specs/fresh-agent-mobile.spec.ts +``` + +Expected: all PASS. If a broad run fails, treat it as a real defect until proven unrelated and fixed or documented with evidence. + +- [ ] **Step 5: Final main integration safety check** + +If `origin/main` moved since Task 1, merge it into the feature branch in this worktree and re-run the final verification. Never merge directly on main. + +Run: + +```bash +git fetch origin +git rev-list --left-right --count HEAD...origin/main +``` + +If the right-side count is nonzero: + +```bash +git merge origin/main +npm run lint +npm run audit:codex-app-server-schema +npm run build +FRESHELL_TEST_SUMMARY="freshcodex contract foundation post-main-merge" npm test +npm run test:e2e:chromium -- \ + test/e2e-browser/specs/fresh-agent.spec.ts \ + test/e2e-browser/specs/fresh-agent-mobile.spec.ts +``` + +Expected: clean merge or resolved conflicts in the worktree, all final gates pass after the merge. If the right-side count is zero, do not create a no-op merge commit. + +- [ ] **Step 6: Commit** + +```bash +git add \ + docs/index.html \ + test/e2e-browser/specs/fresh-agent.spec.ts \ + test/e2e-browser/specs/fresh-agent-mobile.spec.ts \ + docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md +git commit -m "Document and verify Freshcodex contract foundation" +``` + +If `docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md` was not modified, omit it from `git add`. + +## Final Acceptance Checklist + +- `shared/fresh-agent-contract.ts` owns typed schemas for snapshots, turn pages, turn bodies, items, provider extensions, and action results. +- `test/fixtures/fresh-agent/contract-traceability.ts` covers every shared fresh-agent durable schema with producer, parser, state, UI, fixture, and test owners. +- `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` covers every generated Codex client request, response, server request, server notification, item variant, runtime leaf type, and explicit omission; the traceability tests pass without unclassified generated surfaces. +- Server adapters and runtime manager parse every fresh-agent payload before returning it. +- Client API parses fresh-agent payloads and surfaces controlled errors. +- Session-type registry and runtime-provider adapter registry are separate; `freshclaude` and `kilroy` can share the Claude adapter without overwriting provider lookup. +- `src/store/freshAgentSlice.ts` and `src/store/freshAgentTypes.ts` are real fresh-agent state modules, not aliases/re-exports of agent-chat modules. +- `src/store/freshAgentThunks.ts` and `src/lib/pane-activity.ts` are fresh-agent-aware, key Freshcodex state by full `{ sessionType, provider, sessionId }` locators, and do not require Freshcodex sessions to exist in legacy agent-chat state. +- Codex app-server client supports thread fork, turn start, turn interrupt, notifications, and server-request responses according to generated local app-server schemas. +- Codex notification routing is generated-method-specific: `thread/started` routes by `params.thread.id`, ordinary thread events route by `params.threadId`, nullable/global warnings do not fake a thread target, and connection-scoped unsupported notifications such as `command/exec/outputDelta` and `fs/changed` never invalidate an arbitrary subscribed Freshcodex thread. +- Codex server-initiated request ids round-trip unchanged through pending approval/question state, WebSocket response actions, adapter response serialization, and error events whether the generated JSON-RPC id is a string or an integer number. +- Codex protocol schemas and fixtures reject impossible partial app-server entities, while accepting and normalizing generated-optional JSON wire fields. Generated-required fields such as `Thread.turns`, `Thread.cwd`, and `Thread.updatedAt` are required in tests and runtime parsing; generated-optional fields such as response cursors, optional thread metadata, turn timing/error fields, and lifecycle response defaults normalize to explicit `null`/default values before fresh-agent contracts depend on them. +- Codex implemented client-request parameter schemas are strict generated-shape schemas. They preserve all supported generated runtime override fields and reject stale Freshell-only or misspelled outbound fields before a request reaches app-server. +- Codex model pages and provider capability reads cross REST, adapter, and settings UI through typed fresh-agent schemas; provider-level `namespaceTools`, `imageGeneration`, and `webSearch` booleans are not lost as unknown model-item fields. +- Codex generated leaf types for runtime settings, user input, statuses, and session/subagent source metadata are checked into the reduced schema fixture snapshot and covered by inventory tests; `Thread.source` preserves generated nested `SessionSource` / `SubAgentSource` metadata while `thread/list` filters use generated `ThreadSourceKind` values. +- Codex transcript items are fully normalized; no raw transcript item arrays cross the fresh-agent boundary. +- Codex reasoning items preserve both generated `summary` and generated `content` arrays. +- Codex item-level `"inProgress"` statuses for command executions, file changes, MCP tool calls, dynamic tool calls, and collab-agent calls normalize to fresh-agent `"running"` statuses and are covered by schema-valid fixtures. +- Codex file-change items preserve generated `update.move_path` as normalized `movePath` and render rename/move details in diff UI. +- Codex collaboration transcript items preserve all generated `receiverThreadIds` and child-agent state metadata, and the shared UI renders every receiver id instead of collapsing child-agent calls to one thread. +- Codex image-generation items preserve raw generated status strings, including statuses outside Freshell's small display-state buckets. +- Codex generated item detail fields remain contract-visible: text elements, hook run ids, agent-message phase/memory citations, command source/action metadata, MCP resource/result metadata, dynamic-tool output content, web-search actions, and image-generation `result`/`savedPath` are not flattened away. +- Codex MCP and dynamic tool-call `arguments` preserve arbitrary generated `JsonValue` and are not narrowed to object records in shared contracts, normalization, Redux state, or renderers. +- Codex app-server notifications and server requests flow through the rich stdio runtime into fresh-agent subscriptions; live turns, items, token usage, status, diffs, review, compaction, child-thread/collaboration, and thread metadata updates refresh subscribed browsers. +- Codex runtime-global server requests without a generated thread locator, currently auth-token refresh, are answered with valid JSON-RPC error envelopes and surfaced as typed Freshcodex runtime errors instead of hanging or attaching to an arbitrary thread. Legacy `applyPatchApproval` and `execCommandApproval` use generated `conversationId` as their Freshcodex thread locator and answer with root `ReviewDecision` response shapes. +- Freshcodex renders without `agentChat` session state. +- Freshcodex normal snapshot and transcript paths are page-first; they do not load the full Codex thread body list for every snapshot or visible-row hydration. +- Freshcodex supports create, resume, send text/images with runtime settings, interrupt, fork, approvals, questions, diff/review/worktree/child-thread display, reconnect, retry, and stale revision recovery. +- Freshcodex starts Codex review through `review/start`, preserves `reviewThreadId`/target/delivery metadata, lists/resumes rich Codex threads through paginated `thread/list`, exposes loaded thread ids according to `thread/loaded/list`, and populates model/capability UI from paginated `model/list` plus `modelProvider/capabilities/read`. +- Freshcodex history APIs preserve `thread/list` `nextCursor` and `backwardsCursor`, and history queries explicitly include Codex `cli`, `vscode`, `exec`, rich `appServer`, and all generated child-agent source kinds rather than relying on app-server defaults. +- Freshcodex settings/model APIs preserve `model/list` `nextCursor`; any dropdown convenience helper that returns a full option array explicitly drains pages and guards against cursor loops instead of truncating at the first page. +- Freshcodex create/resume settings are Codex-shaped across picker creation, history open, pane persistence, remote tab snapshots, and attach; `sandbox`, generated Codex approval policies, and generated Codex effort values are not dropped or narrowed to Claude-only types. +- Restored Freshcodex panes send attach context and load/resume the Codex app-server thread before snapshot or action work after a browser reload, server restart, or app-server process restart. Every restore/resume path uses `thread/resume { excludeTurns: true }` and then `thread/turns/list` for the visible page so restore cannot accidentally load a full transcript. +- Existing raw Codex terminal panes still launch through the websocket app-server planner and receive a valid loopback `wsUrl`. +- Long Freshcodex transcripts use paging and virtualization. +- Mobile Freshcodex composer, banners, and transcript remain usable with keyboard inset changes. +- Existing Freshclaude and hidden Kilroy paths still pass their targeted tests. +- No storage-clearing migration is introduced. +- `npm run lint`, `npm run audit:codex-app-server-schema`, `npm run build`, coordinated `npm test`, and targeted Freshcodex browser specs pass. diff --git a/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md b/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md new file mode 100644 index 000000000..6b2fd903d --- /dev/null +++ b/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md @@ -0,0 +1,474 @@ +# Freshcodex Contract Foundation Test Plan + +> Reconciles the testing strategy against `docs/plans/2026-04-30-freshcodex-contract-foundation.md`. Strategy is internally consistent; no cost or scope changes required. + +## Strategy Reconciliation + +The implementation plan's Strategy Gate calls for (1) shared Zod contracts as the architectural center, (2) separating session-type identity from runtime-provider adapter lookup, (3) a real fresh-agent slice independent of agent-chat, (4) a Codex stdio rich runtime, (5) full normalization of every generated Codex item/request/notification, and (6) page-first turn body hydration. Every architectural prescription maps to concrete implementation tasks with matching test requirements. + +**No strategy changes requiring user approval.** + +External dependency: `codex app-server` (Codex CLI 0.128.0). The plan handles this by checking in reduced generated-schema snapshots to `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/` with a `schema-inventory.ts` helper so normal test runs have zero external `codex` dependency. A developer-only `scripts/audit-codex-app-server-schema.ts` reconciles the snapshot against the live CLI. + +--- + +## Harness Requirements + +The existing test harnesses are sufficient. No new harnesses need to be built before tests can proceed. + +### Harness: Vitest unit (`test:vitest`) + +What it does: Runs unit and integration tests through Vitest with jsdom or node environment. +What it exposes: Fixture-based component render (Testing Library + Redux store), mocked WS/API, `vi.hoisted()` for module-level mocks. +Tests depending on it: All unit and server tests in sections 1-5 below. + +### Harness: Playwright E2E browser (`test:e2e:chromium`) + +What it does: Launches a full Freshell server in Chromium, exercises the real browser UI with `@playwright/test` and `freshellPage`, `harness`, `terminal` fixtures. +What it exposes: `window.__FRESHELL_TEST_HARNESS__` dispatch, route interception, real WebSocket, screenshot comparison. +Tests depending on it: Section 6 (E2E browser tests) and mobile E2E tests. + +--- + +## Test Plan + +### Priority 0 — E2E Browser Tests: Full UI Drive-Through With Screenshots + +**This is the most important section.** These Playwright E2E tests run against a real Freshell server, use Redux state injection to seed every visual state, exercise the full UI through orchestration where applicable, and capture screenshots at key checkpoints. All tests that exercise a real Codex runtime use the cheapest available model (`gpt-5.4-nano`, $0.20/$1.25 per MTok input/output, as of May 2026) with reasoning effort `none` or `minimal` for cost control; one dedicated test toggles thinking from low to high and back to validate the effort toggle end-to-end. + +**Harness:** Playwright E2E (`page`, `harness`, `testServer`, `api` fixtures from `test/e2e-browser/helpers/fixtures.ts`). Server is an isolated `TestServer` per worker. Redux state injected via `window.__FRESHELL_TEST_HARNESS__`. API responses for fresh-agent endpoints mocked with `page.route()` using contract-valid fixtures from `test/fixtures/fresh-agent/`. Orchestration actions use the MCP REST API at `POST /api/orchestrate`. + +**Cost control for real Codex tests (E2E-13):** All tests use `model: 'gpt-5.4-nano'` (the cheapest current OpenAI model at $0.20/$1.25 per MTok input/output, sourced live from platform.openai.com/docs/models May 2026), `effort: 'none'` or `'minimal'`. The effort toggle test (E2E-13.6) is the sole exception — it switches to `effort: 'high'` for one turn, then back to `'minimal'`. Total estimated cost per full E2E-13 run: ~$0.10-$0.30. + +#### E2E-1: Pane Picker — Freshcodex Entry Creation (4 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-1.1 | Picker shows Freshcodex when codex CLI enabled, hides kilroy and freshopencode | Enable `codex` CLI via Redux. Open PanePicker via context menu on terminal. Assert "Freshcodex" button with icon, label, shortcut hint. Assert `freshopencode` absent. Assert `kilroy` absent. Assert "Freshclaude" present if Claude enabled. Screenshot the picker open. | Picker open with Freshcodex entry | +| E2E-1.2 | Picker supports keyboard nav to Freshcodex, Enter creates pane | Open PanePicker. Press ArrowDown until Freshcodex focused (check aria focus ring). Press Enter. Assert fresh-agent pane appears in layout with `kind: 'fresh-agent'`, `sessionType: 'freshcodex'`. Press Escape on another picker open — assert picker closes, no pane created. | Focus ring on entry; pane created | +| E2E-1.3 | Picker hides Freshcodex when codex CLI disabled | Disable `codex` CLI in settings. Assert "Freshcodex" entry gone from picker. Re-enable. Assert it returns. Screenshot before/after. | Before/after toggle | +| E2E-1.4 | Picker creates pane with correct defaults from Codex provider settings | Seed settings with `codingCli.providers.codex: { model: 'gpt-5.4-nano', sandbox: 'workspace-write', permissionMode: 'on-request' }` and `freshAgent.providers.freshcodex: { defaultEffort: 'low' }`. Click Freshcodex. Inspect Redux state: assert new pane has `model: 'gpt-5.4-nano'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'low'`, `status: 'creating'`. Assert pane header shows "Freshcodex" title. | Pane header with Freshcodex badge | + +#### E2E-2: Freshcodex Shell — All Lifecycle States (9 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-2.1 | Creating state shows spinner and disables composer | Inject `updatePaneContent` with `status: 'creating'`. Assert "Creating session..." text visible. Assert composer input disabled or hidden. Assert no transcript content. Screenshot creating state. | Shell creating state | +| E2E-2.2 | Create failed with runtime unavailable shows error + retry | Inject `status: 'create-failed'`, `createError: { code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', message: 'Codex app-server not found', retryable: true }`. Assert red error banner with message. Assert "Retry" button. Click Retry — assert new `freshAgent.create` WS message sent with incremented `createRequestId`. Assert error banner cleared and status returns to `creating`. | Error banner with retry | +| E2E-2.3 | Create failed with unsupported settings shows non-retryable error | Inject `createError: { code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING', message: '...', retryable: false }`. Assert error message mentions the invalid setting. Assert NO Retry button. Screenshot. | Settings error (no retry) | +| E2E-2.4 | Idle state: composer enabled, empty transcript welcome | Inject `freshAgent.created` then `freshAgentSnapshotReceived` with `status: 'idle'`, `summary: 'Ready'`, `capabilities: { send: true, interrupt: false, ... }`, `initialTurnPage.turns: []`. Assert composer enabled with placeholder "Send a message...". Assert empty transcript shows welcome text. Assert Interrupt button not visible. Assert Send button enabled only when text present. | Idle shell with empty transcript | +| E2E-2.5 | Running state: interrupt button active, composer disabled, items streaming | Inject snapshot with `status: 'running'`, `capabilities.interrupt: true`, active turn with `status: 'inProgress'` items. Assert composer shows Interrupt button (not Send). Assert "Running..." status badge. Assert transcript items visible (agent message with streaming indicator). Assert Send button not shown. | Shell running with items | +| E2E-2.6 | Compacting state shows compaction indicator | Inject snapshot with `status: 'compacting'`, `tokenUsage.compactPercent: 45`. Assert "Compacting..." status visible. Assert composer disabled. Assert transcript still shows prior items. | Compacting indicator | +| E2E-2.7 | Exited state shows ended status, composer offers new session | Inject snapshot with `status: 'exited'`, completed turns. Assert "Session ended" status. Assert composer area shows "Session ended — create a new one" or equivalent end-state message. Assert all transcript items still scrollable and readable. | Exited state | +| E2E-2.8 | Lost session error with retry | Emit WS `freshAgent.error` with `code: 'FRESH_AGENT_LOST_SESSION'`, `retryable: true`. Assert error banner visible. Assert compose area disabled. Click Retry — assert attach WS message sent. | Lost session error | +| E2E-2.9 | Runtime unavailable error with retry | Emit WS `freshAgent.error` with `code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE'`. Assert "runtime unavailable" message. Assert retry restarts connection. | Runtime unavailable | + +#### E2E-3: Composer — Text, Images, Interrupt, Keyboard (9 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-3.1 | Text compose and send dispatches correct WS message | Inject idle snapshot. Find composer textbox by accessible label. Type "Build a React component". Click "Send" button. Assert WS message captured by harness contains `type: 'freshAgent.send'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `text: 'Build a React component'`. Assert composer is empty after send. | Before send (text in composer); after (empty) | +| E2E-3.2 | Send carries runtime settings from pane content | Create pane with `model: 'gpt-5.4-nano'`, `effort: 'minimal'`. Type "hi" and send. Assert WS message has `runtimeSettings: { model: 'gpt-5.4-nano', effort: 'minimal' }`. | — | +| E2E-3.3 | Image attachment via URL input | Click "Attach image" (accessible button). Enter URL `https://example.test/screenshot.png`. Assert image preview thumbnail visible in composer. Assert media type inferred as `image/png`. Type "Review this" and send. Assert WS message `images: [{ kind: 'url', url: 'https://example.test/screenshot.png', mediaType: 'image/png' }]`. | Image preview in composer | +| E2E-3.4 | Image attachment via file upload converts to data URI | Use `page.setInputFiles` on file input to upload a 1x1 PNG. Assert image preview rendered. Assert WS message `images: [{ kind: 'data', mediaType: 'image/png', data: <base64> }]`. | Uploaded image preview | +| E2E-3.5 | Remove attached image before send | Attach image via URL. Assert preview shown. Click remove/delete button on preview. Assert preview gone. Assert "Send" button now disabled (no text either). Type text without image. Send. Assert NO `images` in WS message. | Before/after remove | +| E2E-3.6 | Send button disabled when empty, enabled with text or image | Assert Send button is `disabled` when composer empty. Type text — assert enabled. Clear text — assert disabled. Attach image (no text) — assert enabled. Remove image — assert disabled again. | Disabled/enabled states | +| E2E-3.7 | Interrupt active turn dispatches interrupt WS message | Inject snapshot with `status: 'running'`, `capabilities.interrupt: true`. Assert "Interrupt" button visible (replaces Send button). Click Interrupt. Assert WS `freshAgent.interrupt` sent with `sessionType: 'freshcodex'`. Assert button shows spinner while awaiting response. | Interrupt button active | +| E2E-3.8 | Enter sends, Shift+Enter inserts newline | Focus composer. Type "line1", press Shift+Enter, type "line2". Assert composer shows two lines. Press Enter. Assert WS send dispatched with multiline text. | Newline in composer | +| E2E-3.9 | Composer disabled during create/attach | Inject `status: 'creating'` — assert composer input is disabled. Inject `freshAgent.created` with `status: 'idle'` — assert composer enables. | Disabled→enabled | + +#### E2E-4: Transcript — All Item Kinds, Paging, Virtualization (19 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-4.1 | All item kinds render with correct semantic labels | Seed snapshot with one body turn containing all 17 normalized item kinds. Assert each renders: message with role, command with output, file_change with path, reasoning with summary, plan, review, web_search, hook_prompt, image, image_generation, collaboration, context_compaction, dynamic_tool, request_prompt, error, tool. Screenshot full transcript. | Full transcript with all items | +| E2E-4.2 | User message multi-part: text + image + mention + skill | Inject message with content parts: text "Use this", image URL, mention "README.md" at "/repo/README.md", skill "reviewer" at "/repo/.codex/skills/reviewer/SKILL.md". Assert text, image, mention chip with path, skill chip with path all rendered. | Multi-part user message | +| E2E-4.3 | Agent message markdown rendering | Inject agent message with markdown: headers, code blocks, bold, lists. Assert rendered with proper formatting (code blocks monospace, bold text bold). | Formatted agent response | +| E2E-4.4 | Command item: all 5 status variants | Inject command items in each status: pending (spinner), running (spinner + partial output), completed (checkmark + output + exitCode 0), failed (X + output + exitCode 1), declined (declined badge). Assert each shows correct status badge and content. | Command status variants | +| E2E-4.5 | File change with expandable diff | Inject file_change with changes: `{ path: 'src/app.ts', diff: '@@ -1 +1 @@ ...' }`. Assert path visible. Assert diff collapsed by default. Click "View diff" toggle — assert diff content rendered. Click again — assert collapsed. | Collapsed/expanded diff | +| E2E-4.6 | Reasoning: collapsed by default, expand to reveal full text | Inject reasoning with `summary: ['Let me think...']`, `text: 'Detailed reasoning...'`. Assert summary visible, not full text. Click expand — assert full text visible. Click collapse — assert hidden. | Collapsed/expanded reasoning | +| E2E-4.7 | Context compaction shows token deltas | Inject `context_compaction` with `beforeTokens: 50000`, `afterTokens: 20000`. Assert "Compacted context" badge with token reduction visible. | Compaction item | +| E2E-4.8 | Review entered/exited items | Inject review items for `entered` and `exited` phases. Assert "Entered review mode" and "Exited review mode" badges. | Review transitions | +| E2E-4.9 | Error item renders message and code | Inject error item with `message: 'Connection lost'`, `code: 'TIMEOUT'`. Assert error message and code badge visible. | Error transcript item | +| E2E-4.10 | Request prompt item — pending shows prompt, no action in transcript | Inject `request_prompt` with `requestKind: 'approval'`, `status: 'pending'`. Assert prompt text, pending badge. Assert action is in banner (not card). | Pending request in transcript | +| E2E-4.11 | Request prompt item — resolved shows no actions | Same item with `status: 'resolved'`. Assert "Resolved" badge. Assert no action buttons. | Resolved request | +| E2E-4.12 | Turn page: load more turns via cursor pagination | Mock API to return page with `nextCursor: 'c2'`. Seed 3 turn summaries. Assert 3 rows visible. Click "Load more" or scroll to bottom. Assert API called with `cursor: 'c2'`. Assert 6 rows after load. | Before/after page load | +| E2E-4.13 | Virtualized list: 1000 turns but only visible rows in DOM | Seed 1000 turn summaries. Render at 600px container. Query DOM for turn rows — assert <25 rendered. Scroll to middle — assert only visible rows in DOM. Assert `aria-setsize: 1000` on list. | Virtualized scroll position | +| E2E-4.14 | Body hydration from page-provided items (no separate request) | Seed page where turn-2 has `body` popuplated. Assert turn-2 items render directly. Turn-1 has no body — assert "Load body" button. Click it — assert API `getFreshAgentTurnBody` called. Assert body renders. | Page body; loaded body | +| E2E-4.15 | Stale revision error during body load | Mock body API rejects with `{ code: 'FRESH_AGENT_STALE_THREAD_REVISION' }`. Click "Load body" on turn-1. Assert "session changed" error toast/message. Assert snapshot refresh triggered. | Stale revision error | +| E2E-4.16 | Dynamic tool item — declined with reason | Inject `dynamic_tool` with `status: 'declined'`, `name: 'unsupported-tool'`, `reason: 'Not supported'`. Assert decline reason visible. | Declined dynamic tool | +| E2E-4.17 | Collaboration item shows cross-thread metadata | Inject `collaboration` with `tool: 'code-reviewer'`, `senderThreadId: 't1'`, `receiverThreadId: 't2'`, `newThreadId: 't3'`. Assert thread IDs and tool name visible. | Collaboration item | +| E2E-4.18 | Image generation item shows result | Inject `image_generation` with `prompt: 'diagram'`, `status: 'completed'`, `imageUrl: 'https://example.test/gen.png'`. Assert generated image renders. | Generated image | +| E2E-4.19 | Web search item shows query | Inject `web_search` with `query: 'React hooks'`, `status: 'completed'`. Assert query and status visible. | Web search result | + +#### E2E-5: Workspace Panel — Worktrees, Child Threads, Diffs, Review, Fork, Tokens (11 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-5.1 | Panel toggle: open sidebar button, click panel content | Click workspace panel toggle (icon in shell header). Assert panel slides open. Assert panel has accessible `region` role with label. Click overlay or toggle again — assert panel closes. | Panel open; closed | +| E2E-5.2 | Worktree list rendered from snapshot | Inject snapshot with `worktrees: [{ name: 'feature/freshcodex', path: '/repo', branch: 'feature/freshcodex' }]`. Open panel. Assert worktree section with branch name and path. | Worktree section | +| E2E-5.3 | Child threads section with clickable entries | Inject `childThreads: [{ threadId: 'thread-child-1', title: 'Review shell', source: 'review' }]`. Assert child thread entry with title and source badge. Click — assert navigates to that pane if in layout. | Child thread entry | +| E2E-5.4 | Fork lineage shows parent thread | Inject extensions codex fork: `{ parentThreadId: 'thread-parent-1' }`. Open panel. Assert fork lineage section shows "Forked from" with parent thread ID. | Fork lineage | +| E2E-5.5 | Diff list shows file paths with change kind badges | Inject `diffs: [{ path: 'src/app.ts', changeKind: 'modify', summary: 'Updated logic' }]`. Assert diff entry with file path and "modified" badge. | Diff list | +| E2E-5.6 | Review status section with output | Inject review metadata with status "complete" and output text. Assert review section shows status and output rendered. | Review output | +| E2E-5.7 | Start review button: enabled dispatches WS | Inject `capabilities.review: true`, `status: 'idle'`. Click "Start review" in panel. Assert WS `freshAgent.review.start` with `target: { type: 'uncommittedChanges' }`, `delivery: 'inline'`. Assert button shows loading. | Review start → loading | +| E2E-5.8 | Start review button: disabled with reason label | Inject `capabilities.review: false`. Assert "Start review" button disabled. Assert tooltip/label "Review not supported by this provider." | Disabled review | +| E2E-5.9 | Token usage display | Inject `tokenUsage: { inputTokens: 5000, outputTokens: 1200, totalTokens: 6200, contextTokens: 45000, compactPercent: 30 }`. Assert token counts in panel with labels. | Token section | +| E2E-5.10 | Fork action: button → WS → new pane | Inject `capabilities.fork: true`. Click "Fork session" in panel. Assert WS `freshAgent.fork` sent. Simulate WS response `freshAgent.forked` with `sessionId: 'thread-fork-1'`, `parentThreadId: 'thread-1'`. Assert new sibling pane appears with `sessionId: 'thread-fork-1'`. Screenshot layout with two panes. | Fork → sibling pane | +| E2E-5.11 | Panel collapses to compact sidebar on narrow viewport | Resize pane to 350px wide. Assert panel collapses to icon-only buttons (worktree, diff, child, fork, review icons). Resize to 800px. Assert full panel returns. | Compact sidebar; full panel | + +#### E2E-6: Approval Banners — All Request Types (6 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-6.1 | Command approval: accept/decline/acceptForSession | Inject `pendingApprovals: [{ requestId: 'cmd-1', kind: 'command_approval', title: 'Run: npm test', command: 'npm test' }]`. Assert banner with command text. Click "Accept" — assert WS response `kind: 'command_approval'`, `decision: 'accept'`. Banner dismissed. | Banner; after accept | +| E2E-6.2 | File change approval: three decision options | Inject file_change_approval with file path. Assert "Accept", "Accept for session", "Decline" buttons. Test each dispatches correct WS response. | File change approval | +| E2E-6.3 | Permissions approval: turn vs session scope | Inject permission approval banner. Click "Grant for turn" — assert WS `scope: 'turn'`. Repeat with "Grant for session" — assert `scope: 'session'`. Assert `strictAutoReview` toggle if present. | Permissions with scope | +| E2E-6.4 | Multiple stacked banners render in order | Inject 2 pending approvals. Assert both rendered in order. Resolve first — assert first removed, second remains. | Stacked banners | +| E2E-6.5 | Banner dismisses on snapshot refresh | Inject pending approval. Assert banner visible. Refresh snapshot via WS event with empty `pendingApprovals`. Assert banner removed. | Banner dismissed | +| E2E-6.6 | Banner handles cancel response | Inject approval. Click "Cancel" — assert WS response `decision: 'cancel'`. Assert banner dismissed. | Cancel action | + +#### E2E-7: Question Banners — User Input and MCP Elicitation (5 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-7.1 | Tool user input: select option, submit with array answers | Inject `pendingQuestions: [{ requestId: 'ui-1', kind: 'tool_user_input', title: 'Choose', prompt: 'Select:', fields: [{ name: 'choice', options: ['a', 'b', 'c'], multi: true }] }]`. Select 'a' and 'b'. Click Submit. Assert WS response `kind: 'tool_user_input'`, `answers: { choice: { answers: ['a', 'b'] } }`. | Question with selection | +| E2E-7.2 | Tool user input: multiple fields | Inject question with fields `name` (text input) and `description` (text area). Fill both. Assert response has answers for both keys. | Multi-field question | +| E2E-7.3 | MCP elicitation: accept with content | Inject `kind: 'mcp_elicitation'`, `title: 'Confirm', prompt: 'Approve?'`. Click "Accept". Assert WS `kind: 'mcp_elicitation'`, `action: 'accept'`, `content: expect.any(Object)`, `_meta: null`. | MCP accept | +| E2E-7.4 | MCP elicitation: decline | Click "Decline". Assert WS `action: 'decline'`. Assert banner dismissed. | Decline | +| E2E-7.5 | Banners have accessible labels and keyboard operable | Tab through all interactive elements in question banner. Assert each field/button receives focus with visible ring. Assert each action button has `aria-label`. | Keyboard focus | + +#### E2E-8: Fork, Review, and Cross-Pane Lifecycle via Orchestration (6 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-8.1 | Full fork flow: source pane → sibling pane appears | Seed Freshcodex pane with `sessionId: 'thread-1'`, `capabilities.fork: true`. Open workspace panel, click Fork. Assert WS `freshAgent.fork` sent. Inject WS `freshAgent.forked` with `sessionId: 'thread-fork-1'`, `parentThreadId: 'thread-1'`. Assert new sibling pane in layout. Assert new pane `sessionId: 'thread-fork-1'`, `sessionType: 'freshcodex'`. Inspect Redux for correct pane tree. Screenshot layout. | Layout with two Freshcodex panes | +| E2E-8.2 | Forked pane loads independent snapshot | After fork, inject `freshAgent.created` + `freshAgentSnapshotReceived` for forked pane with different turn content than parent. Assert forked pane shows its own turns, not parent's. Assert forked workspace panel shows `parentThreadId` in fork lineage. | Forked pane with own content | +| E2E-8.3 | Review flow: start → started → panel shows results | Inject `capabilities.review: true`. Click "Start review". Assert WS sent. Inject `freshAgent.review.started`. Assert "Review started" indicator. Inject snapshot update with review items in transcript. Assert workspace panel shows review output. | Review started; results in panel | +| E2E-8.4 | Review with commit target | Select commit SHA target in review options (if UI exposes it). Assert WS includes `target: { type: 'commit', sha: ... }`. | Custom review target | +| E2E-8.5 | Fork via orchestration MCP API | POST `/api/orchestrate { action: 'fork', params: { target: 'thread-1' } }`. Assert new pane created. Assert `freshAgent.forked` emitted. | MCP-orchestrated fork | +| E2E-8.6 | Send input via orchestration MCP | POST `/api/orchestrate { action: 'send-keys', params: { target: 'thread-1', keys: 'Implement login', literal: true } }`. Assert text appears in composer and send is dispatched. | Orchestration input | + +#### E2E-9: Mobile Viewport — Keyboard, Touch, Layout (5 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-9.1 | Composer sticky above keyboard | Set iPhone 14 viewport (390x844). Inject idle snapshot. Simulate software keyboard via CSS variable `--keyboard-inset-bottom: 300px`. Assert composer is sticky at viewport bottom, not hidden behind keyboard. Assert `enterkeyhint='send'`. | Mobile with keyboard | +| E2E-9.2 | Transcript scrollable with keyboard open | Seed 10 items. Simulate keyboard open. Assert transcript container adjusts height. Assert last item scrollable into view. | Mobile transcript with keyboard | +| E2E-9.3 | Approval buttons meet 44px minimum touch target | Inject approval at mobile viewport. Measure button heights — assert ≥44px or equivalent padding. Assert accessible labels on all buttons. | Mobile approval buttons | +| E2E-9.4 | Workspace panel as bottom sheet on mobile | Open workspace panel on mobile. Assert it appears as bottom sheet (not side panel). Assert swipe-down handle or close button. | Mobile bottom sheet | +| E2E-9.5 | PanePicker full-width on mobile | Open PanePicker on mobile viewport. Assert near-full width. Assert scrollable if many entries. | Mobile picker | + +#### E2E-10: Multi-Client and Reconnect Resilience (4 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-10.1 | Two clients subscribed to same thread both refresh on notification | Open two browser contexts connected to same TestServer. Both create panes for same thread. Inject `turn/completed` notification via server WS. Assert both clients receive snapshot invalidation and refresh. Assert both show updated content. | Two clients side-by-side | +| E2E-10.2 | Reconnect after WS drop preserves state, sends attach not create | Seed pane with `sessionId: 'thread-1'`. Force-disconnect WS via `harness.forceDisconnect()`. Wait for reconnect. Assert pane sends `freshAgent.attach` with existing `sessionId`, NOT `freshAgent.create`. Assert snapshot reloads. Assert turn history still visible. | After reconnect | +| E2E-10.3 | Notification burst debounces to single refresh | Inject 5 `turn/started` notifications in rapid succession (<100ms). Poll harness for sent REST requests — assert only 1 snapshot fetch. Assert final state reflects latest notification. | Debounced refresh | +| E2E-10.4 | Server restart recovery: error state → retry → reloaded | Stop TestServer. Assert all panes show disconnected/error. Restart server. Click Retry on Freshcodex pane. Assert pane reattaches and reloads snapshot with same turns. | Before/after recovery | + +#### E2E-11: Settings — Model, Effort, Sandbox, Capabilities (6 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-11.1 | Settings dialog shows Codex model list from mocked app-server | Mock `/api/fresh-agent/models/codex` with model list. Open Settings → Freshcodex section. Assert model dropdown populated with model names. Assert currently selected model highlighted. | Settings dialog | +| E2E-11.2 | Effort dropdown shows only Codex values (no 'max') | Open effort dropdown. Assert options: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Assert `max` NOT present. Assert current selection matches persisted value. | Effort dropdown | +| E2E-11.3 | Sandbox selector: read-only, workspace-write, danger-full-access | Cycle through sandbox options. Assert each value reflected in Redux after save. Reload page — assert sandbox persisted. | Sandbox selector | +| E2E-11.4 | Freshcodex settings independent from Claude settings | Open Freshcodex settings, set model to "gpt-5.4-nano". Switch to Freshclaude settings — assert different model visible. Switch back — assert Freshcodex values preserved. | Independent settings | +| E2E-11.5 | Unavailable saved model shows "(Unavailable)" | Mock capabilities where saved model is absent. Assert "(Unavailable)" label next to model. Assert info text about fallback. | Unavailable model | +| E2E-11.6 | Settings on mobile as bottom sheet | Open settings on iPhone 14 viewport. Assert full-height bottom sheet. Assert scrollable. Assert Save/Cancel sticky at bottom. | Mobile settings sheet | + +#### E2E-12: Session Persistence and Restore (4 tests) + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-12.1 | Full settings survive page reload | Create full Freshcodex pane: `model: 'gpt-5.4-nano'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'low'`, `initialCwd: '/repo'`. Reload page. Assert pane restored with ALL fields intact. Assert attach WS sent with `cwd` and `runtimeSettings`. | After reload | +| E2E-12.2 | Legacy pane with Claude-only values shows controlled createError, not dropped | Pre-populate localStorage with a pane having `permissionMode: 'bypassPermissions'`, `effort: 'max'`. Load page. Assert pane renders (not dropped). Assert createError banner with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING`. Assert pane header still shows "Freshcodex". | Legacy pane error | +| E2E-12.3 | Granular approval policy survives persistence round-trip | Set `permissionMode: { granular: { sandbox_approval: true, rules: false, skill_approval: true, request_permissions: true, mcp_elicitations: false } }`. Reload. Assert full object preserved (not cast to string, not narrowed to enum). | Granular policy preserved | +| E2E-12.4 | Tab snapshot captures Freshcodex metadata for remote sessions | Create Freshcodex pane with settings. Trigger tab snapshot via Redux (`collectPaneSnapshots`). Assert snapshot payload includes `sandbox`, `permissionMode`, `effort`, `sessionType`, `provider`. Assert effort is `'xhigh'` not `'max'` if xhigh was set. | Tab snapshot content | + +#### E2E-13: Real Codex Runtime Integration — Cost-Controlled (10 tests) + +These tests connect to a real `codex` app-server via the rich stdio runtime. Each uses `model: 'gpt-5.4-nano'` (OpenAI's cheapest current model, $0.20/$1.25 per MTok input/output) with `effort: 'none'` or `'minimal'` except E2E-13.6 which toggles effort to `'high'` once. All cost ~$0.005-$0.02 per turn. Skip gracefully (`test.skip(!codexAvailable)`) in CI without Codex. + +| # | Name | What It Drives | Screenshot States | +|---|------|----------------|-------------------| +| E2E-13.1 | Real: create pane → real thread created, snapshot loads | Create Freshcodex pane with `model: 'gpt-5.4-nano'`, `effort: 'minimal'`. Wait for `freshAgent.created` + snapshot load. Assert snapshot `status: 'idle'`. Assert `threadId` is a real UUID. Assert pane header shows "Freshcodex". Screenshot the connected pane. | Real connected pane | +| E2E-13.2 | Real: send message → turn runs → items appear → idle again | Type "Say hello in exactly one short sentence." Send. Assert composer disabled, status → "Running". Wait for turn items: user message and agent message. Assert agent message content visible. Wait for status → "idle". Assert composer re-enabled. Screenshot the completed turn. | Real turn complete | +| E2E-13.3 | Real: interrupt mid-execution | Send: "List recursively all files in the current directory and describe each one." Wait for turn to start (status → "Running"). Click Interrupt. Assert WS `freshAgent.interrupt` sent. Wait for status → "idle" with turn showing interrupted status. Assert last turn has items from before interrupt. | Interrupted turn | +| E2E-13.4 | Real: fork creates new pane with real forked thread | After E2E-13.2 turn completes, click Fork. Wait for `freshAgent.forked`. Assert new sibling pane appears with new thread ID. Assert forked pane loads independently. Assert workspace panel shows `parentThreadId`. | Forked panes | +| E2E-13.5 | Real: send with image input | Attach a small test PNG. Type "Describe this image very briefly." Send. Wait for turn. Assert user message content includes image part. Assert agent responds. | Image input turn complete | +| E2E-13.6 | Real: effort toggle low → high → low | Create pane with `effort: 'minimal'`, `model: 'gpt-5.4-nano'`. Send "What is 2+2?" — completes with minimal effort. Change settings to `effort: 'high'`. Send "What is 3+3?" — completes with high effort (verify via metadata). Change back to `effort: 'minimal'`. Send "What is 4+4?" — back to minimal. Screenshot each state. | Effort toggle: low, high, low | +| E2E-13.7 | Real: approval flow — command approval banner and respond | Set `permissionMode: 'on-request'`. Send "Run: ls -la" or a command that triggers approval. Wait for `pendingApprovals` in snapshot. Assert command approval banner with command text. Click "Accept". Assert command executes and output appears in turn. | Approval banner → executed | +| E2E-13.8 | Real: runtime unavailable → restart → recovery | Kill the Codex app-server child process. Assert pane shows `FRESH_AGENT_RUNTIME_UNAVAILABLE` error. Restart app-server (or click Retry). Assert pane reconnects, attaches to same thread, reloads snapshot with prior turns visible. | Error → recovered | +| E2E-13.9 | Real: token usage updates after turn | After E2E-13.2 completes, check snapshot or WS for `tokenUsage` update with non-zero tokens. Assert `totalTokens > 0`. Assert workspace panel shows token counts. | Token counts | +| E2E-13.10 | Real: model list fetches from live app-server | Open settings. Fetch model list from real app-server. Assert list includes entries with `id`, `displayName`, `defaultReasoningEffort`. Screenshot settings with real models. | Real model list | + +--- + +### Priority 1 — Existing red checks that must go green + +The current suite has no known CI-blocking red tests in this worktree, but the following characterization checks must be verified green after Task 1 (main merge) before proceeding. + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 1 | **Main-origin Codex app-server client tests remain green after merge** | regression | existing | Vitest unit | **Preconditions:** `origin/main` merged into worktree branch. **Actions:** Run `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`. **Expected outcome:** All pass. Merge conflicts resolved without deleting fresh-agent tests or reverting main's app-server fixes. **Source of truth:** `npm test` on `origin/main`. **Interactions:** Codex launch-planner, raw terminal attach path. | +| 2 | **Main-origin panesSlice and SDK WS handler tests remain green after merge** | regression | existing | Vitest unit | **Preconditions:** `origin/main` merged. **Actions:** Run `test/unit/client/store/panesSlice.test.ts test/unit/server/ws-handler-sdk.test.ts`. **Expected outcome:** All pass. Stale-hydration protection and reconnect recovery preserved. **Source of truth:** Main-origin test expectations. **Interactions:** Pane hydration, WebSocket handshake. | +| 3 | **FreshAgentView renders without crashing after merge** | regression | existing | Vitest unit | **Preconditions:** Merge complete. **Actions:** Run `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`. **Expected outcome:** All pass. **Source of truth:** Existing test expectations on this branch. **Interactions:** WS mock, API mock. | + +### Priority 2 — High-value existing integration and scenario tests + +These existing tests exercise the real product surface (Redux store, React components, Express routes, WebSocket handlers) and must continue passing through the entire refactor. + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 4 | **Freshclaude panes render, send, interrupt, and answer questions through fresh-agent surface** | scenario | existing | Vitest unit | **Preconditions:** Store seeded with fresh-agent pane. **Actions:** Render `FreshAgentView` for freshclaude, verify snapshot renders, send text, click interrupt, click question answer button. **Expected outcome:** Snapshot content visible, WS messages dispatched with correct `type`, sessionId, sessionType, provider. **Source of truth:** Freshclaude existing behavior and `shared/ws-protocol.ts` message schemas. **Interactions:** WebSocket client, API client, freshAgent reducer. | +| 5 | **Freshcodex panes create and render initial snapshot** | scenario | existing | Vitest unit | **Preconditions:** Store seeded with freshcodex pane. **Actions:** Render `FreshAgentView` for freshcodex, assert create WS message sent, deliver `freshAgent.created`, assert snapshot loaded. **Expected outcome:** Create WS message includes sessionType and provider; snapshot appears in DOM. **Source of truth:** `shared/fresh-agent.ts` descriptor table. **Interactions:** WebSocket, API. | +| 6 | **Fresh Agent E2E: picker entries appear when CLIs are enabled** | scenario | existing | Playwright E2E | **Preconditions:** Freshell server running, both `claude` and `codex` CLIs enabled. **Actions:** Open pane picker via context menu on xterm. **Expected outcome:** Freshclaude and Freshcodex entries visible. `freshopencode` not shown (disabled). Kilroy not shown (hidden). **Source of truth:** `shared/fresh-agent.ts` descriptors. **Interactions:** PaneContainer, PanePicker, settings slice. | +| 7 | **Fresh Agent E2E: freshclaude banners render and answer over WS** | integration | existing | Playwright E2E | **Preconditions:** Freshclaude pane created, API routes mocked with Claude-shaped snapshot including pending approvals/questions. **Actions:** Wait for banner render, click approve/answer buttons. **Expected outcome:** WS messages sent with correct `type`, answers/decisions. Banners dismissed. **Source of truth:** `shared/ws-protocol.ts`. **Interactions:** WebSocket, REST API, banners, composer. | + +### Priority 3 — New integration and scenario tests to close gaps + +These tests cover behavior gaps where the existing suite does not test the right surface or with enough fidelity. + +#### Contract foundation (Tasks 2-3) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 8 | **Shared contract schemas validate valid Codex snapshots, turn pages, turn bodies, items, and action responses** | invariant | new | Vitest unit | **Preconditions:** `shared/fresh-agent-contract.ts` implemented. **Actions:** Parse valid fixtures through every schema (`FreshAgentThreadSnapshotSchema`, `FreshAgentTurnPageSchema`, `FreshAgentTurnBodySchema`, `FreshAgentTranscriptItemSchema`, `FreshAgentServerRequestResponseSchema`, `FreshAgentThreadListPageSchema`, `FreshAgentRuntimeSettingsSchema`). **Expected outcome:** All parse successfully. Thread-list fixture preserves `items`, `nextCursor`, `backwardsCursor` (not collapsed to array). **Source of truth:** Generated Codex 0.128.0 schemas + implementation plan contract section. **Interactions:** Client API boundary, server adapter boundary. | +| 9 | **Shared contract schemas reject invalid statuses, kinds, and missing required fields** | boundary | new | Vitest unit | **Preconditions:** Schemas implemented. **Actions:** Pass `status: 'creating'` (invalid), `kind: 'raw'` (invalid item), snapshot missing `revision`, etc. **Expected outcome:** Each throws ZodError with specific path/message. **Source of truth:** `FreshAgentThreadStatusSchema` enum, `FreshAgentTranscriptItemSchema` discriminator. **Interactions:** Error handling in boundary layers. | +| 10 | **Shared contract schemas accept generated Codex effort values and reject legacy Claude effort `max` at Codex boundary** | boundary | new | Vitest unit | **Preconditions:** `FreshAgentRuntimeSettingsSchema` implemented. **Actions:** Parse `effort: 'xhigh'` and `effort: 'max'`. **Expected outcome:** `xhigh` passes; `max` is accepted by the shared schema (union with legacy) but `FreshAgentCodexReasoningEffortSchema` rejects `max`. **Source of truth:** Generated `ReasoningEffort.ts` values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`). **Interactions:** Codex adapter validation, create/resume/send runtime-settings mappers. | +| 11 | **Shared contract schemas accept Codex approval policies and reject Claude bypassPermissions at Codex boundary** | boundary | new | Vitest unit | **Preconditions:** `FreshAgentCodexApprovalPolicySchema` implemented. **Actions:** Parse `untrusted`, `on-failure`, `on-request`, `never`, and granular `{ granular: { sandbox_approval: true, ... } }` objects; reject `bypassPermissions`. **Expected outcome:** Codex values parse; Claude-only values rejected by Codex-specific schema. **Source of truth:** Generated `AskForApproval.ts`. **Interactions:** Codex adapter runtime-settings mappers. | +| 12 | **Shared contract schemas preserve multi-part user message content with text, images, mentions, and skills** | invariant | new | Vitest unit | **Preconditions:** `FreshAgentMessageContentPartSchema` implemented. **Actions:** Parse message with all five content part kinds (`text`, `image`, `image` with `path`, `mention`, `skill`). **Expected outcome:** All parts preserved in discriminated union. **Source of truth:** Generated `UserInput.ts` variants (`text`, `image`, `localImage`, `skill`, `mention`). **Interactions:** Transcript item renderer, Codex normalize. | +| 13 | **Shared contract schemas cover every Codex transcript item kind from generated schema** | invariant | new | Vitest unit | **Preconditions:** `FreshAgentTranscriptItemSchema` implemented. **Actions:** Table-driven parse of all 16 generated `ThreadItem` variants through `FreshAgentTranscriptItemSchema`. **Expected outcome:** Each variant maps to expected normalized kind. **Source of truth:** Checked-in `ThreadItem.ts` snapshot via `schema-inventory.ts`. **Interactions:** Item card renderer, normalize. | +| 14 | **FreshAgentServerRequestResponseSchema preserves discriminated kind shapes for all server request types** | invariant | new | Vitest unit | **Preconditions:** Schema implemented. **Actions:** Parse `command_approval` (decision), `file_change_approval` (decision), `permissions_approval` (permissions, scope, strictAutoReview), `tool_user_input` (answers with array), `mcp_elicitation` (action, content, _meta). **Expected outcome:** Each kind preserves its generated response shape - not collapsed to string answers/decisions. **Source of truth:** Generated response schemas for each server-request method. **Interactions:** Adapter respondToServerRequest, WebSocket action, controller. | +| 15 | **FreshAgentInputImageSchema accepts url, local path, and data URL images** | boundary | new | Vitest unit | **Preconditions:** Schema implemented. **Actions:** Parse `{ kind: 'url', url: 'https://...' }`, `{ kind: 'local', path: '/tmp/img.png' }`, `{ kind: 'data', data: 'base64...', mediaType: 'image/png' }`. **Expected outcome:** All three varieties parse. **Source of truth:** Generated `UserInput.ts` image types + Freshell browser upload representation. **Interactions:** Composer image upload, adapter send. | + +#### Provider registry and session routing (Task 3) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 16 | **Provider registry keeps session-type identity separate from runtime-provider adapter lookup** | invariant | new | Vitest unit | **Preconditions:** Registry split into `sessionTypes` and `runtimeAdapters`. **Actions:** Register `freshclaude` and `kilroy` both with `runtimeProvider: 'claude'`; resolve both by session type and by runtime provider. **Expected outcome:** `resolveBySessionType('freshclaude').adapter === resolveBySessionType('kilroy').adapter`. `resolveByRuntimeProvider('claude')` returns the same Claude adapter regardless of registration order. No Map-overwrite side effect. **Source of truth:** Implementation plan invariant "freshclaude and kilroy sharing the Claude adapter must be an intentional many-to-one mapping, not a Map overwrite side effect." **Interactions:** Runtime manager create/attach/resolve. | +| 17 | **Runtime manager routes actions by full session locator, not bare sessionId** | invariant | new | Vitest unit | **Preconditions:** Two sessions attached with same `sessionId` but different `sessionType`/`provider` (Claude and Codex). **Actions:** Send action targeting Codex locator. **Expected outcome:** Codex adapter receives the action; Claude adapter does not. **Source of truth:** Implementation plan locator rule. **Interactions:** All action handlers (send, interrupt, fork, respondToServerRequest, startReview). | +| 18 | **Runtime manager rejects actions when session locator does not match the attached record** | boundary | new | Vitest unit | **Preconditions:** Session attached as `freshcodex/codex/thread-1`. **Actions:** Send action with `sessionType: 'freshclaude', provider: 'claude', sessionId: 'thread-1'`. **Expected outcome:** Rejected with `FRESH_AGENT_SESSION_LOCATOR_MISMATCH`. **Source of truth:** Implementation plan locator mismatch rule. **Interactions:** WS handler, client dispatcher. | +| 19 | **Runtime manager parses every snapshot through FreshAgentThreadSnapshotSchema before returning** | invariant | new | Vitest unit | **Preconditions:** Adapter returns invalid snapshot (e.g., `status: 'creating'`). **Actions:** Call `manager.getSnapshot(...)`. **Expected outcome:** Throws `FreshAgentContractValidationError` with code `FRESH_AGENT_CONTRACT_INVALID`, surface `'snapshot'`, and Zod issues. **Source of truth:** `FreshAgentThreadSnapshotSchema`. **Interactions:** Router 502 mapping. | +| 20 | **REST router returns HTTP 502 for contract-invalid adapter snapshots** | integration | new | Vitest unit (supertest) | **Preconditions:** App with invalid snapshot adapter. **Actions:** GET `/api/fresh-agent/threads/codex/thread-1`. **Expected outcome:** Status 502, response body contains `{ code: 'FRESH_AGENT_CONTRACT_INVALID' }`. **Source of truth:** Implementation plan router 502 mapping. **Interactions:** Express error handler, client API. | +| 21 | **Client API surfaces controlled FreshAgentApiPayloadError for invalid snapshot responses** | integration | new | Vitest unit | **Preconditions:** API returns invalid fresh-agent payload (e.g., `status: 'creating'`). **Actions:** Call `getFreshAgentThreadSnapshot(...)`. **Expected outcome:** Rejected with `{ code: 'FRESH_AGENT_CONTRACT_INVALID' }`. **Source of truth:** `src/lib/fresh-agent-api-error.ts` schema. **Interactions:** Controller, shell error display. | +| 22 | **freshAgentSlice is independent from legacy agentChatSlice, with fresh-agent action names** | invariant | new | Vitest unit | **Preconditions:** New `freshAgentSlice.ts` implemented. **Actions:** Assert reducer identity !== agentChatReducer. Dispatch `freshAgentSnapshotReceived(validCodexSnapshot)`. **Expected outcome:** State has `sessions['thread-codex-1']` with `sessionType: 'freshcodex'`, `provider: 'codex'`. Action type prefix starts with `freshAgent/`. **Source of truth:** Implementation plan "must become an actual fresh-agent slice with fresh-agent action names." **Interactions:** Client WS handler, controller. | +| 23 | **Pane activity resolution reads fresh-agent sessions independently of agent-chat sessions** | invariant | new | Vitest unit | **Preconditions:** Mock `freshAgentSessions` with running Codex session; `agentChatSessions` is empty. **Actions:** Call `resolvePaneActivity` for a fresh-agent pane. **Expected outcome:** Returns `{ isBusy: true, source: 'fresh-agent' }` without reading `agentChatSessions`. **Source of truth:** Implementation plan "fresh-agent panes use the new fresh-agent session state." **Interactions:** Sidebar busy indicators, tab badges, session keys. | + +#### Codex app-server protocol (Task 4) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 24 | **Schema-inventory captures all generated client/server request/notification methods and type inventories** | invariant | new | Vitest unit | **Preconditions:** Checked-in generated schema snapshot in `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/`. **Actions:** Parse method names from `ClientRequest.ts`, `ServerRequest.ts`, `ServerNotification.ts`. Parse field-requiredness and enum values from `v2/Thread.ts`, `v2/Turn.ts`, `v2/ThreadItem.ts`, etc. **Expected outcome:** Tests read from checked-in text snapshots via `fs` + `import.meta.url` (no external `codex` dependency). Every generated type's required fields extracted, every enum value captured. **Source of truth:** Local `codex app-server generate-ts --out ...` (0.128.0). **Interactions:** Protocol audit script. | +| 25 | **Checked-in generated schema tests fail when protocol.ts accepts partial/missing required fields** | invariant | new | Vitest unit | **Preconditions:** `schema-inventory.ts` exposes `requiredFieldsForGeneratedType()`. **Actions:** Parse `{ id: 'thread-missing-fields' }` through `CodexThreadSchema`. Parse `{ data: [] }` through `CodexThreadTurnsListResultSchema`. Parse `{ data: [] }` through `CodexThreadListResultSchema`. **Expected outcome:** Each fails because required fields (`turns`, `cwd`, `createdAt`, `nextCursor`, `backwardsCursor`) are missing. **Source of truth:** Generated schema required-field inventory. **Interactions:** All protocol consumer code. | +| 26 | **Stdio JSONL transport emits no jsonrpc field and delivers one message per newline-delimited line** | integration | new | Vitest unit | **Preconditions:** `CodexStdioJsonlTransport` with fake child process. **Actions:** Send `{ id: 1, method: 'initialize', params }`. Push `{ id: 1, result }` on stdout. **Expected outcome:** Stdin receives JSON without `jsonrpc`. Transport resolves result message. No `jsonrpc` in any emitted line. **Source of truth:** Codex app-server wire format. **Interactions:** Rich runtime, client. | +| 27 | **WebSocket transport preserves existing loopback framing behavior without jsonrpc** | invariant | new | Vitest unit | **Preconditions:** `CodexWebSocketTransport` with fake WS. **Actions:** Send messages, receive messages. **Expected outcome:** Messages serialized without `jsonrpc`. WS URL creation preserved. Close/error propagated. **Source of truth:** Existing raw Codex terminal WS behavior. **Interactions:** Raw terminal `--remote` attach, launch planner. | +| 28 | **Codex client sends initialize, then exactly one initialized notification, before other requests** | integration | new | Vitest unit | **Preconditions:** `CodexAppServerClient` on fake transport. **Actions:** Call `client.initialize()`, then `client.readThread(...)`. **Expected outcome:** Transport sent `initialize`, then `initialized`, then `thread/read` — in that exact order. Only one `initialized` notification. **Source of truth:** Codex app-server protocol spec. **Interactions:** Rich runtime startup. | +| 29 | **Codex client handles server-initiated requests and responds on the same JSON-RPC id** | integration | new | Vitest unit | **Preconditions:** Client initialized. Listener registered via `onServerRequest`. **Actions:** Fake server sends request `{ id: 'approval-99', method: 'item/commandExecution/requestApproval', params }`. Client calls `respondToServerRequest('approval-99', { decision: 'accept' })`. **Expected outcome:** Response envelope `{ id: 'approval-99', result: { decision: 'accept' } }` sent on transport. Listener invoked with original request. **Source of truth:** Codex JSON-RPC request/response semantics. **Interactions:** Adapter approval flow. | +| 30 | **Codex client surfaces runtime-global server request (auth token refresh) without inventing thread id** | boundary | new | Vitest unit | **Preconditions:** Client initialized. **Actions:** Server sends `{ id: 'auth-1', method: 'account/chatgptAuthTokens/refresh', params }`. Client calls `respondToServerRequestError('auth-1', { code: -32050, message: 'Freshell cannot refresh...' })`. **Expected outcome:** Error envelope `{ id: 'auth-1', error: { code: -32050 } }` sent. Request surfaced without `threadId` attached. **Source of truth:** Generated `ServerRequest.ts` (auth method has no threadId in params). **Interactions:** All subscribed Freshcodex pane runtime-error broadcasts. | +| 31 | **Codex client forwards notifications to subscribers without treating them as request responses** | invariant | new | Vitest unit | **Preconditions:** Client with `onNotification` listener. **Actions:** Server sends `{ method: 'turn/started', params }`. **Expected outcome:** Listener invoked. No pending request count change. Response handler NOT invoked. **Source of truth:** JSON-RPC notification spec (no `id`). **Interactions:** Adapter event subscription. | +| 32 | **Codex client exposes all implemented rich methods through typed signatures** | invariant | new | Vitest unit | **Preconditions:** Client with fake transport. **Actions:** Call `startTurn`, `interruptTurn`, `forkThread`, `listThreads`, `listLoadedThreads`, `listThreadTurns`, `readThread`, `startReview`, `listModels`, `readModelProviderCapabilities`. **Expected outcome:** All resolve with schema-typed results. No `readThreadTurn` method exists on client. **Source of truth:** Implementation plan implemented method list. **Interactions:** Adapter, rich runtime. | +| 33 | **Rich stdio runtime starts app-server on stdio://, proxies rich methods after ensureReady, and has no wsUrl** | integration | new | Vitest unit | **Preconditions:** `CodexRichAppServerRuntime` with fake child process. **Actions:** Call `ensureReady()`, then `startThread(...)`. **Expected outcome:** Child process launched with `--listen stdio://`, stdio pipes configured. `startThread` resolves with `{ threadId }` — no `wsUrl` property. **Source of truth:** Implementation plan "Freshcodex-only stdio runtime must not return or require a wsUrl." **Interactions:** Freshcodex adapter. | +| 34 | **Rich stdio runtime subscribes to notifications and server requests for a specific thread** | integration | new | Vitest unit | **Preconditions:** Runtime ready, subscribed for `thread-1`. **Actions:** Notifications arrive for `thread-1` and `thread-2`. **Expected outcome:** Only `thread-1` notifications reach subscriber. Runtime-global requests without threadId still reach all subscribers. **Source of truth:** Implementation plan subscribe behavior. **Interactions:** Adapter event listener, snapshot invalidation. | +| 35 | **Raw websocket runtime preserves existing behavior for terminal --remote attach** | regression | new | Vitest unit | **Preconditions:** `CodexAppServerRuntime` with fake WS child. **Actions:** `startThread(...)`. **Expected outcome:** Returns `{ threadId, wsUrl }`. WS URL is a localhost WS URL. Child process launched with `--listen ws://...`. **Source of truth:** Existing `CodexLaunchPlanner` contract. **Interactions:** Raw Codex terminal panes, launch planner. | +| 36 | **Rich runtime shutdown kills the stdio child without affecting the websocket runtime process** | boundary | new | Vitest unit | **Preconditions:** Both runtimes active. **Actions:** `await richRuntime.shutdown()`. **Expected outcome:** Stdio child process killed. Websocket runtime status stays `running`. **Source of truth:** Implementation plan independent runtime lifecycle. **Interactions:** Server shutdown sequence. | + +#### Codex normalization and adapter (Task 5) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 37 | **Every generated Codex ThreadItem variant normalizes to a valid FreshAgentTranscriptItem without raw-ish records** | scenario | extend | Vitest unit | **Preconditions:** All 16 generated ThreadItem types modeled. **Actions:** Parse each through `CodexThreadItemSchema`, then `normalizeCodexItem()`. **Expected outcome:** Table-driven — each variant produces a discriminated union `FreshAgentTranscriptItem` with the correct `kind`. Unknown future item types throw `UnsupportedCodexItemError`. No raw `Array<Record<string, unknown>>`. **Source of truth:** Checked-in `ThreadItem.ts` snapshot inventory + `FreshAgentTranscriptItemSchema`. **Interactions:** Turn normalization, transcript renderer. | +| 38 | **Codex userMessage with mixed content parts normalizes to a single message item with all content parts preserved** | scenario | new | Vitest unit | **Preconditions:** UserMessage with text, image, localImage, mention, and skill parts. **Actions:** Normalize through `normalizeCodexItem()`. **Expected outcome:** Returns single-element array with `kind: 'message'`, `role: 'user'`, and 5 content parts preserving each generated type. **Source of truth:** Generated `UserInput.ts` + implementation plan "preserve every generated part type." **Interactions:** Message content renderer, mention/skill chips. | +| 39 | **Codex turn normalization does not synthesize a turn-level role for Codex** | invariant | new | Vitest unit | **Preconditions:** Codex turn with mixed user/assistant items. **Actions:** Normalize through `normalizeCodexTurnBody()`. **Expected outcome:** Turn-level `role` is undefined. User/assistant roles live only on `message` transcript items. **Source of truth:** Implementation plan "role belongs on message transcript items." **Interactions:** Turn rendering, virtual list. | +| 40 | **Codex UNIX-second timestamps normalize to ISO strings** | invariant | new | Vitest unit | **Preconditions:** Codex turn with `startedAt: 1714780000` and `completedAt: 1714780060`. **Actions:** Normalize. **Expected outcome:** `startedAt: '2024-05-04T...Z'`, `completedAt: '2024-05-04T...Z'`. **Source of truth:** Implementation plan "fresh-agent UI contract uses ISO strings." **Interactions:** Turn summary display, transcript timeline. | +| 41 | **Codex adapter send converts text and typed images to Codex UserInput array** | integration | new | Vitest unit | **Preconditions:** Mock rich runtime. **Actions:** `adapter.send('thread-1', { text: 'Ship it', images: [{ kind: 'url', url: 'https://...', mediaType: 'image/png' }] })`. **Expected outcome:** `runtime.startTurn` called with `input: [{ type: 'text', text: 'Ship it', text_elements: [] }, { type: 'image', url: 'https://...' }]`. **Source of truth:** Generated `UserInput.ts` + `CodexTurnStartParamsSchema`. **Interactions:** Composer, controller. | +| 42 | **Codex adapter send converts data URIs and local paths to correct Codex input types** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** Send with `{ kind: 'data', mediaType: 'image/png', data: 'AQID' }` and `{ kind: 'local', path: '/repo/img.png', mediaType: 'image/png' }`. **Expected outcome:** Data image becomes `{ type: 'image', url: 'data:image/png;base64,AQID' }`. Local becomes `{ type: 'localImage', path: '/repo/img.png' }`. **Source of truth:** Generated `UserInput.ts`. **Interactions:** Browser file upload, paste. | +| 43 | **Codex adapter rejects legacy Claude-only runtime settings before calling Codex app-server** | boundary | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.send('thread-1', { runtimeSettings: { permissionMode: 'bypassPermissions', effort: 'max' } })`. **Expected outcome:** Rejected with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` before any RPC call. **Source of truth:** Implementation plan "must not send Claude permission modes such as bypassPermissions as Codex approvalPolicy." **Interactions:** Settings validation, controller. | +| 44 | **Codex adapter send includes runtime settings (model, sandbox, approvalPolicy, effort) in turn start** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** Send with all runtime settings: `model: 'mock-codex-model'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'xhigh'`. **Expected outcome:** `runtime.startTurn` called with matching `model`, appropriate `sandboxPolicy` (not string `sandbox`), correct `approvalPolicy`, and `effort`. **Source of truth:** Generated `TurnStartParams.ts`, `CodexReasoningEffortSchema`, `CodexApprovalPolicySchema`. **Interactions:** Settings persistence, pane creation. | +| 45 | **Codex adapter interrupt finds active turn from live state and calls turn/interrupt** | scenario | new | Vitest unit | **Preconditions:** Active turn `turn-running-1` tracked in live state. **Actions:** `adapter.interrupt('thread-1')`. **Expected outcome:** `runtime.interruptTurn` called with `{ threadId: 'thread-1', turnId: 'turn-running-1' }`. **Source of truth:** Generated `TurnInterruptParams.ts`. **Interactions:** Interrupt button, composer. | +| 46 | **Codex adapter interrupt with no active turn returns FRESH_AGENT_NO_ACTIVE_TURN** | boundary | new | Vitest unit | **Preconditions:** No active turn. **Actions:** `adapter.interrupt('thread-1')`. **Expected outcome:** Rejected with `FRESH_AGENT_NO_ACTIVE_TURN`. **Source of truth:** Implementation plan error code. **Interactions:** UI interrupt button disabled state. | +| 47 | **Codex adapter fork creates new thread, returns fresh-agent fork result with parent thread id** | integration | new | Vitest unit | **Preconditions:** Mock runtime returns `{ thread: schemaValidThread({ id: 'thread-fork-1' }), ... }`. **Actions:** `adapter.fork('thread-1', { excludeTurns: true })`. **Expected outcome:** Resolves with `{ sessionId: 'thread-fork-1', sessionType: 'freshcodex', runtimeProvider: 'codex', parentThreadId: 'thread-1' }`. **Source of truth:** Generated `ThreadForkResultSchema` + implementation plan fork result shape. **Interactions:** Fork button, forked pane creation. | +| 48 | **Codex adapter startReview calls review/start with uncommittedChanges and inline delivery by default** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.startReview('thread-1')`. **Expected outcome:** `runtime.startReview` called with `{ threadId: 'thread-1', target: { type: 'uncommittedChanges' }, delivery: 'inline' }`. Resolves with `{ turnId, reviewThreadId, target, delivery }`. **Source of truth:** Generated `ReviewStartParams.ts`, `ReviewStartResponse.ts`. **Interactions:** Review start button, workspace panel. | +| 49 | **Codex adapter server-request approval mapping: command/file approvals become pending approvals** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/commandExecution/requestApproval`. **Actions:** Assert listener fires with `type: 'freshAgent.snapshot.invalidate'`. Call `getSnapshot`. **Expected outcome:** `pendingApprovals` includes request with `requestId` derived from original server request. **Source of truth:** Generated `ServerRequest.ts` approval methods. **Interactions:** Approval banner, controller. | +| 50 | **Codex adapter server-request mapping: user input and MCP elicitation requests become pending questions** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/tool/requestUserInput` and `mcpServer/elicitation/request`. **Actions:** Get snapshot. **Expected outcome:** `pendingQuestions` includes both request types with correct `kind`. **Source of truth:** Generated `ServerRequest.ts`. **Interactions:** Question banner, controller. | +| 51 | **Codex adapter server-request mapping: dynamic tool call gets auto-declined with clear user-visible response** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/tool/call`. **Expected outcome:** Adapter responds immediately with `{ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], success: false }` on the correct JSON-RPC id. **Source of truth:** Generated `DynamicToolCallResponse.ts` + implementation plan auto-decline rule. **Interactions:** Dynamic tool item card. | +| 52 | **Codex adapter respondToServerRequest dispatches generated-shape responses for each request kind** | integration | new | Vitest unit | **Preconditions:** Pending requests of each kind. **Actions:** Call `respondToServerRequest` with `tool_user_input` (answers with arrays), `mcp_elicitation` (action, content, _meta), `permissions_approval` (permissions, scope). **Expected outcome:** `runtime.respondToServerRequest` called with the original JSON-RPC id and the generated-shape payload — not collapsed to string answers or Claude-style answers. **Source of truth:** Generated response schemas. **Interactions:** Approval banner responder, question banner responder. | +| 53 | **Codex adapter notification: every visible-state notification method invalidates the snapshot** | scenario | new | Vitest unit | **Preconditions:** Adapter subscribed to `thread-1`. **Actions:** Table-driven: emit each visible-state notification method from `ServerNotification.ts` (turn/started, turn/completed, item/started, item/completed, thread/status/changed, thread/tokenUsage/updated, turn/diff/updated, turn/plan/updated, thread/compacted, thread/name/updated, thread/closed, thread/archived, thread/realtime/error, etc.). **Expected outcome:** Each emits `{ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1', reason: <method> }`. **Source of truth:** Checked-in `ServerNotification.ts` snapshot inventory. **Interactions:** Controller snapshot refresh. | +| 54 | **Codex adapter notification: non-visible notifications are silently ignored per allowlist** | boundary | new | Vitest unit | **Preconditions:** Adapter subscribed. **Actions:** Emit `skills/changed`, `account/updated`, `deprecationNotice`. **Expected outcome:** Listener NOT fired. No snapshot invalidation. **Source of truth:** Implementation plan non-visible allowlist. **Interactions:** Unnecessary refresh avoidance. | +| 55 | **Codex adapter notification: runtime-global auth token refresh emits typed error to all subscribed panes** | scenario | new | Vitest unit | **Preconditions:** Two panes subscribed to different threads. **Actions:** Runtime emits `account/chatgptAuthTokens/refresh` (no threadId). **Expected outcome:** Both listeners receive `{ type: 'freshAgent.error', code: 'FRESH_AGENT_UNSUPPORTED_AUTH_REFRESH', retryable: false }`. JSON-RPC error response sent on original request id. **Source of truth:** Implementation plan runtime-global request handling. **Interactions:** Error banner, retry state. | +| 56 | **Codex adapter getSnapshot is page-first: reads thread metadata without turns, then bounded page for status** | invariant | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.getSnapshot({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' })`. **Expected outcome:** `runtime.readThread` called with `{ threadId: 'thread-new-1', includeTurns: false }`. Does NOT call `readThread` with `includeTurns: true`. Fetches bounded `thread/turns/list { limit: 10, sortDirection: 'desc' }` for active turn detection. **Source of truth:** Implementation plan "snapshot must not load the full Codex thread body list." **Interactions:** Controller snapshot loading. | +| 57 | **Codex adapter getTurnBody serves from bounded turn-body cache populated by page loads** | invariant | new | Vitest unit | **Preconditions:** Page loaded with turn bodies cached. **Actions:** `adapter.getTurnBody(...)`. **Expected outcome:** Returns cached body. If not cached, throws `FRESH_AGENT_TURN_BODY_NOT_LOADED`. Never calls a nonexistent `thread/turn/read` or `thread/read { includeTurns: true }`. **Source of truth:** Implementation plan "body hydration must not be implemented by repeatedly calling thread/read." **Interactions:** Transcript body expansion. | +| 58 | **Codex adapter ensureThreadLoaded resumes thread when not loaded in fresh app-server process** | scenario | new | Vitest unit | **Preconditions:** `readThread` returns `status: { type: 'notLoaded' }`. **Actions:** `adapter.getSnapshot(...)`. **Expected outcome:** `runtime.resumeThread` called with attach context, then `readThread` retried. Resolves with loaded snapshot. **Source of truth:** Implementation plan "fresh app-server process does not necessarily have browser-restored thread ids loaded." **Interactions:** Browser reload, server restart, reconnect. | + +#### Controller, shell, and composer (Task 6) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 59 | **FreshAgentShell renders freshcodex snapshot without depending on agentChat state** | scenario | new | Vitest unit | **Preconditions:** Store has `freshAgent` state but no `agentChat` state. **Actions:** Render `FreshAgentShell` with freshcodex snapshot props. **Expected outcome:** Summary text, status label, capabilities visible. No crash from missing `agentChat.sessions`. **Source of truth:** Implementation plan independence requirement. **Interactions:** All fresh-agent components. | +| 60 | **Controller sends Freshcodex text, images, and runtime settings in freshAgent.send WS message** | scenario | new | Vitest unit | **Preconditions:** Freshcodex pane with model/sandbox/permission/effort values. **Actions:** Type text in composer, attach image URL, click Send. **Expected outcome:** WS message sent with `type: 'freshAgent.send'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `text`, `images` array, and `runtimeSettings` including model/sandbox/permissionMode/effort. **Source of truth:** `FreshAgentSendSchema` in `shared/ws-protocol.ts`. **Interactions:** WS client, adapter send. | +| 61 | **Controller sends attach context for restored Freshcodex pane with cwd and runtime settings** | scenario | new | Vitest unit | **Preconditions:** Pane with saved `sessionId`, `initialCwd`, `model`, `sandbox`, `permissionMode`, `effort`. **Actions:** Render pane. **Expected outcome:** WS `freshAgent.attach` message sent with `sessionId`, `sessionType`, `provider`, `cwd`, and `runtimeSettings`. **Source of truth:** `FreshAgentAttachSchema`. **Interactions:** Adapter ensureThreadLoaded. | +| 62 | **Controller converts uploaded browser files to data image inputs before dispatch** | scenario | new | Vitest unit | **Preconditions:** File upload (image/png). **Actions:** Upload file, type text, send. **Expected outcome:** WS message includes `images: [{ kind: 'data', mediaType: 'image/png', data: <base64> }]`. **Source of truth:** `FreshAgentInputImageSchema` `kind: 'data'` variant. **Interactions:** File input, paste handler. | +| 63 | **Controller does not clobber newer pane fields when freshAgent.created arrives late** | boundary | new | Vitest unit | **Preconditions:** Pane has `model`, `initialCwd`, user-set title mutated after create sent. **Actions:** Deliver `freshAgent.created` message. **Expected outcome:** Only `sessionId`, `resumeSessionId`, `status`, and `createError` changed. Model, title, cwd preserved. **Source of truth:** Implementation plan "Apply async field updates through mergePaneContent, not full replace." **Interactions:** Pane persistence, title editing. | +| 64 | **Controller opens forked thread in a sibling pane on freshAgent.forked message** | scenario | new | Vitest unit | **Preconditions:** Source pane with `sessionId: 'thread-1'`. **Actions:** Emit WS `{ type: 'freshAgent.forked', sourceSessionId: 'thread-1', sessionId: 'thread-fork-1', sessionType: 'freshcodex', runtimeProvider: 'codex', parentThreadId: 'thread-1' }`. **Expected outcome:** New pane added to layout tree with `kind: 'fresh-agent'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `sessionId: 'thread-fork-1'`. **Source of truth:** Implementation plan forked pane creation. **Interactions:** panesSlice, tabsSlice. | +| 65 | **Controller responds to Codex request-user-input with generated answer array shape** | scenario | new | Vitest unit | **Preconditions:** Snapshot with `pendingQuestions` containing `tool_user_input` request. **Actions:** Click answer button. **Expected outcome:** WS `freshAgent.serverRequest.respond` sent with `response.kind: 'tool_user_input'`, `response.answers: { choice: { answers: ['a'] } }`. Not collapsed to `answers: { choice: 'a' }` or string answers. **Source of truth:** `FreshAgentServerRequestResponseSchema` tool_user_input variant. **Interactions:** Question banner, adapter respondToServerRequest. | +| 66 | **Controller responds to MCP elicitation with generated action/content/meta shape** | scenario | new | Vitest unit | **Preconditions:** Snapshot with `mcp_elicitation` request. **Actions:** Click accept. **Expected outcome:** WS response has `kind: 'mcp_elicitation'`, `action: 'accept'`, `content: {...}`, `_meta: null`. **Source of truth:** `FreshAgentServerRequestResponseSchema` mcp_elicitation variant. **Interactions:** Question banner, adapter. | + +### Priority 4 — Differential tests (reference-based verification) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 67 | **Protocol schema inventory matches checked-in generated schema snapshot** | differential | new | Vitest unit | **Preconditions:** `schema-inventory.ts` reads from checked-in fixture snapshot. **Actions:** Compare extracted method names, enum values, and required fields against hardcoded expected lists. **Expected outcome:** All generated methods classified as either implemented or unsupported. No new unclassified method slips through. **Source of truth:** Local `codex app-server generate-ts` (0.128.0). **Interactions:** `scripts/audit-codex-app-server-schema.ts` dev tool. | +| 68 | **Codex normalization fixtures first parse through generated Codex protocol schemas** | invariant | new | Vitest unit | **Preconditions:** Every fixture calls `CodexThreadSchema.parse()`, `CodexTurnSchema.parse()`, or `CodexThreadItemSchema.parse()` before normalization. **Actions:** Verify no fixture passes an impossible mock shape (missing required fields, wrong enums). **Expected outcome:** Every fixture is schema-valid before normalization. Tests fail if a generated schema changes requiredness. **Source of truth:** Generated schema snapshot. **Interactions:** All normalize tests. | + +### Priority 5 — Invariant tests + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 69 | **Freshagent thread status never leaks raw Codex status objects to the shared contract** | invariant | new | Vitest unit | **Preconditions:** Codex `Thread` with `status: { type: 'active', activeFlags: [...] }`. **Actions:** Normalize through `normalizeCodexThreadStatus()`. **Expected outcome:** Returns `'running'`. Active flags preserved under `extensions.codex` only. Shared contract status is one of `['idle', 'running', 'compacting', 'exited', 'lost', 'error']`. **Source of truth:** `FreshAgentThreadStatusSchema`. **Interactions:** Shell status label, sidebar activity. | +| 70 | **Session locator key is composite: sessionType:provider:sessionId, not sessionId alone** | invariant | new | Vitest unit | **Preconditions:** Runtime manager with sessions. **Actions:** Store two sessions with same `sessionId` but different `sessionType`/`provider`. **Expected outcome:** Both stored independently under different internal keys. Lookup by bare `sessionId` resolves only when exactly one match exists; otherwise throws. **Source of truth:** Implementation plan "key sessions by the full locator." **Interactions:** All adapter dispatch. | +| 71 | **Normalized Codex turns always set source: 'durable'** | invariant | new | Vitest unit | **Preconditions:** Any Codex turn. **Actions:** Normalize. **Expected outcome:** `source: 'durable'`. **Source of truth:** Implementation plan "Codex turns are durable app-server records." **Interactions:** Turn display, paging. | + +### Priority 6 — Boundary and edge-case tests + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 72 | **Empty thread (zero turns) normalizes to valid snapshot with empty initialTurnPage** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `turns: []`. **Actions:** `normalizeCodexThreadSnapshot(...)`. **Expected outcome:** Snapshot with `status: 'idle'`, `initialTurnPage` with empty `turns`, `nextCursor: null`. No error. **Source of truth:** `FreshAgentThreadSnapshotSchema`. **Interactions:** Empty transcript shell display. | +| 73 | **Thread with systemError status normalizes to 'error' status** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `status: { type: 'systemError' }`. **Actions:** Normalize. **Expected outcome:** Snapshot `status: 'error'`. **Source of truth:** `normalizeCodexThreadStatus()` mapping. **Interactions:** Shell error display. | +| 74 | **Thread with active status and waitingOnApproval flag normalizes to 'running' status** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `status: { type: 'active', activeFlags: ['waitingOnApproval'] }`. **Actions:** Normalize. **Expected outcome:** Snapshot `status: 'running'` with `pendingApprovals` populated. **Source of truth:** Generated `ThreadActiveFlag.ts`. **Interactions:** Approval banner display. | +| 75 | **Stale revision error from adapter prevents mixing inconsistent page and body revisions** | boundary | new | Vitest unit | **Preconditions:** Adapter returns `FreshAgentStaleThreadRevisionError(currentRevision: 9)` for body request. **Actions:** Controller catches error. **Expected outcome:** Client shows "session changed while loading" message, does NOT render mismatched body. **Source of truth:** `FreshAgentStaleThreadRevisionError` contract. **Interactions:** Controller stale revision recovery. | +| 76 | **Codex adapter create/resume/send rejects before calling app-server when runtime settings contain Claude-only values** | boundary | new | Vitest unit | **Preconditions:** `permissionMode: 'bypassPermissions'` or `effort: 'max'`. **Actions:** `create`, `resume`, `send`. **Expected outcome:** Each rejects with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` before any `runtime.startThread` / `runtime.startTurn` call. **Source of truth:** `FreshAgentRuntimeSettingsSchema` + Codex specific schemas. **Interactions:** Settings validation, PanePicker creation. | +| 77 | **Codex adapter thread/list returns paginated response with both cursors, sources included** | boundary | new | Vitest unit | **Preconditions:** Mock runtime returns paginated threads. **Actions:** `adapter.listThreads({ limit: 25 })`. **Expected outcome:** Response includes `items` (normalized to `FreshAgentSessionSummary`), `nextCursor`, `backwardsCursor`. Session source metadata (including nested subagent data) preserved. Runtime called with explicit `sourceKinds` including `appServer`, `vscode`, and all `subAgent*` kinds. **Source of truth:** Generated `ThreadListResultSchema`, `SessionSourceSchema`. **Interactions:** History view pagination, sidebar. | +| 78 | **Codex adapter thread/loaded/list returns string ids with nextCursor, not fake session summaries** | boundary | new | Vitest unit | **Preconditions:** Mock runtime returns `{ data: ['thread-1'], nextCursor: null }`. **Actions:** `adapter.listLoadedThreadIds(...)`. **Expected outcome:** Resolves with `{ ids: ['thread-1'], nextCursor: null }`. Does not pretend to return `FreshAgentSessionSummary` rows. **Source of truth:** Implementation plan "loaded-list must not return fake rich session rows." **Interactions:** Session directory, history hydration. | +| 79 | **Codex adapter model/list and modelProvider/capabilities/read surface typed results** | integration | new | Vitest unit | **Preconditions:** Mock runtime returns model list and capabilities. **Actions:** Call `adapter.listModels()` and `adapter.readModelProviderCapabilities()`. **Expected outcome:** Models have `id`, `displayName`, `defaultReasoningEffort`, etc. Capabilities include `webSearch`, `imageGeneration`, `namespaceTools`. **Source of truth:** Generated `ModelListResponse.ts`, `ModelProviderCapabilitiesReadResponse.ts`. **Interactions:** Settings view model picker. | +| 80 | **Thread source with nested subagent metadata preserves parentThreadId, depth, nickname, and role** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `source: { subAgent: { thread_spawn: { parent_thread_id: 'parent-1', depth: 2, agent_nickname: 'reviewer', agent_role: 'review' } } }`. **Actions:** Normalize and project through session directory. **Expected outcome:** `parentThreadId: 'parent-1'` extracted. Subagent source metadata preserved in session summary. Not flattened to `subAgentThreadSpawn` string. **Source of truth:** Generated `SessionSourceSchema`, `SubAgentSourceSchema`. **Interactions:** Child thread panel, fork lineage display. | + +### Priority 7 — Regression tests (preserving existing Freshclaude and Kilroy behavior) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 81 | **Freshclaude snapshot rendering, send, interrupt, approvals, questions still work after contract enforcement** | regression | extend | Vitest unit | **Preconditions:** All boundary parsing active. **Actions:** Run `test/unit/server/fresh-agent/claude-normalize.test.ts`, `test/unit/server/fresh-agent/claude-adapter.test.ts`, `test/unit/server/fresh-agent/claude-restore-contract.test.ts`. **Expected outcome:** All pass. Claude adapters still produce contract-valid output. **Source of truth:** Existing Freshclaude test expectations. **Interactions:** Freshclaude shell, controller. | +| 82 | **Kilroy resolves to Claude runtime adapter through separated registry** | regression | extend | Vitest unit | **Preconditions:** Registry with separated session type descriptors and runtime adapters. **Actions:** `resolveBySessionType('kilroy')`. **Expected outcome:** Returns Claude adapter. `resolveByRuntimeProvider('claude')` also returns the same adapter. Not affected by `freshclaude` registration order. **Source of truth:** Implementation plan invariant. **Interactions:** Kilroy pane behavior. | +| 83 | **Raw Codex terminal panes still launch through websocket app-server planner with valid wsUrl** | regression | existing | Vitest unit | **Preconditions:** Launch planner unchanged. **Actions:** Run `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`, `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`. **Expected outcome:** All pass. Raw terminal `--remote` attach works as before. **Source of truth:** Existing runtime test expectations. **Interactions:** Raw Codex terminal panes. | +| 84 | **Existing Freshclaude saved layouts, settings, remote tab snapshots, and history remain readable after refactor** | regression | extend | Vitest unit | **Preconditions:** Layouts/snapshots with legacy Freshclaude agent-chat content. **Actions:** Parse through migration, assert no data loss. **Expected outcome:** Panes hydrate with correct `kind`, `sessionType`, `provider`. No storage-clearing migration introduced. **Source of truth:** Existing localStorage migration tests. **Interactions:** Persistence, reconnect, tab snapshots. | +| 85 | **Main-branch auto-title, mobile keyboard, stale hydration, reconnect recovery survive the cutover** | regression | extend | Vitest unit | **Preconditions:** All main-origin fixes applied via merge. **Actions:** Run `test/unit/server/title-utils.test.ts`, `test/unit/client/store/panesSlice.test.ts`, reconnect tests. **Expected outcome:** All pass. Auto-title works for fresh-agent sessions using shared title utilities. **Source of truth:** Main-origin test expectations. **Interactions:** FreshAgentShell, tabs, panesSlice. | + +### Priority 8 — Unit tests (pure algorithms and data transformations) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 86 | **normalizeCodexThreadStatus maps all four generated status types to fresh-agent statuses** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Pass `{ type: 'notLoaded' }`, `{ type: 'idle' }`, `{ type: 'systemError' }`, `{ type: 'active', activeFlags: [] }`. **Expected outcome:** `'idle'`, `'idle'`, `'error'`, `'running'` respectively. **Source of truth:** Implementation plan status mapping table. **Interactions:** Snapshot normalization. | +| 87 | **mapFreshcodexSandboxModeToTurnPolicy converts shared sandbox strings to generated SandboxPolicy** | unit | new | Vitest unit | **Preconditions:** `cwd: '/repo'`. **Actions:** Map `'read-only'`, `'workspace-write'`, `'danger-full-access'`, `undefined`. **Expected outcome:** `readOnly` (no network), `workspaceWrite` (with writableRoots), `dangerFullAccess`, `undefined`. **Source of truth:** Generated `SandboxPolicy.ts`. **Interactions:** Adapter send, create. | +| 88 | **mapFreshcodexApprovalPolicy validates Codex approval values at adapter boundary** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Map `'on-request'`, `'never'`, `{ granular: {...} }`, `'bypassPermissions'` (Claude-only). **Expected outcome:** First three return matching values. `'bypassPermissions'` throws. **Source of truth:** `CodexApprovalPolicySchema`. **Interactions:** Adapter send, create. | +| 89 | **mapFreshcodexReasoningEffort validates Codex effort values at adapter boundary** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Map `'xhigh'`, `'medium'`, `'max'`. **Expected outcome:** `'xhigh'` and `'medium'` pass. `'max'` throws. **Source of truth:** `CodexReasoningEffortSchema`. **Interactions:** Adapter send. | +| 90 | **Codex turn normalization flatMaps item arrays so one app-server item can produce multiple transcript items** | unit | new | Vitest unit | **Preconditions:** UserMessage item with 5 content parts. **Actions:** `normalizeCodexTurnBody(...)`. **Expected outcome:** `items` contains one `message` item with 5 content parts (not 5 separate items). Item count matches `flatMap` output, not `rawTurn.items.length`. **Source of truth:** Implementation plan "item normalization must return an array and turn normalization must flatMap item output." **Interactions:** Transcript renderer. | +| 91 | **Fixed-size LRU turn-body cache evicts oldest entry when at capacity** | unit | new | Vitest unit | **Preconditions:** Cache with `maxSize: 2`. **Actions:** Insert 3 entries. **Expected outcome:** First entry evicted. `get` for thread-1:turn-1 returns undefined. **Source of truth:** Implementation plan bounded cache spec. **Interactions:** `getTurnBody` adapter facade. | +| 92 | **isoTimestampFromCodexUnix converts valid Unix seconds and handles null/undefined** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** `(1714780000)`, `(0)`, `(null)`, `(undefined)`. **Expected outcome:** Valid ISO string, `'1970-01-01T00:00:00.000Z'`, `undefined`, `undefined`. **Source of truth:** `new Date(timestamp * 1000).toISOString()`. **Interactions:** Turn body normalization. | + +### Transcript paging and virtualization (Task 7) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 93 | **Snapshot initialTurnPage renders visible turn summaries; bodies hydrate from page-provided items** | scenario | new | Vitest unit | **Preconditions:** API returns page with 2 turn summaries (turn-2 has body). **Actions:** Render transcript. **Expected outcome:** Both summaries visible row. Turn-2 shows full body. Turn-1 shows preview only with "load body" button. **Source of truth:** `FreshAgentTurnPageSchema` with `FreshAgentTurnSummarySchema.body`. **Interactions:** API client, controller. | +| 94 | **Virtualized list does not render off-screen rows from a 1000-turn transcript** | scenario | new | Vitest unit | **Preconditions:** 1000 turn summaries. **Actions:** Render `FreshAgentTranscriptVirtualList` with `availableHeight: 600`, `rowHeight: 80`. **Expected outcome:** Less than 20 rows in DOM. Turn 999 not rendered until scrolled. **Source of truth:** `react-window` `List` component. **Interactions:** Mobile responsiveness. | +| 95 | **Stale revision error during body load shows "session changed" message instead of mixing revisions** | boundary | new | Vitest unit | **Preconditions:** API rejects with `{ code: 'STALE_THREAD_REVISION', currentRevision: 9 }`. **Actions:** Click "load body". **Expected outcome:** Error message visible. No mismatched body rendered. Snapshot refresh triggered. **Source of truth:** `FreshAgentStaleThreadRevisionError`. **Interactions:** Controller body hydration. | +| 96 | **react-window List renders with correct aria attributes on each row** | unit | new | Vitest unit | **Preconditions:** 3 turns. **Actions:** Render virtual list. **Expected outcome:** Each rendered row has `aria-posinset`, `aria-setsize`, `role: 'listitem'`. **Source of truth:** WCAG virtualized list pattern. **Interactions:** Screen reader, browser-use automation. | + +### Freshcodex item rendering, diff, review, worktree, fork UX (Task 8) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 97 | **FreshAgentItemCard renders every transcript item kind with semantic labels** | scenario | new | Vitest unit | **Preconditions:** One of each item kind from fixture. **Actions:** Render each through `FreshAgentItemCard`. **Expected outcome:** Command item shows command, status, output. File change shows path, expandable diff. Message shows role-labeled content. Mention/skill parts render as inline chips with path. Context compaction shows token before/after. **Source of truth:** `FreshAgentTranscriptItemSchema` discriminated union. **Interactions:** Transcript rows in virtual list. | +| 98 | **FreshAgentWorkspacePanel shows worktrees, child threads, diffs, review status, fork lineage, and token details** | scenario | new | Vitest unit | **Preconditions:** Snapshot with worktree, child thread, diff, fork metadata, token usage. **Actions:** Render workspace panel. **Expected outcome:** Each section visible with accessible region labels. Review start button calls WS when capabilities.review is true. **Source of truth:** `FreshAgentThreadSnapshotSchema` extension fields. **Interactions:** Shell sidebar/panel. | +| 99 | **Diff panel renders expandable diff for file changes with accessible toggle** | scenario | new | Vitest unit | **Preconditions:** File change with diff text. **Actions:** Click "view diff" button. **Expected outcome:** Diff content visible, shared `DiffView` component used. Button has accessible label. **Source of truth:** `FreshAgentFileChangeItemSchema`. **Interactions:** Shared DiffView from agent-chat. | +| 100 | **Review start button emits freshAgent.review.start WS message** | scenario | new | Vitest unit | **Preconditions:** Freshcodex pane with `capabilities.review: true`. **Actions:** Click "Start review." **Expected outcome:** WS message sent with `type: 'freshAgent.review.start'`, `sessionId`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `target: { type: 'uncommittedChanges' }`. **Source of truth:** `FreshAgentReviewStartSchema`. **Interactions:** WS handler, adapter startReview. | + +### Mobile keyboard, touch, and performance (Task 9) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 101 | **FreshAgentShell applies keyboard inset padding and composer stays sticky in mobile panes** | scenario | new | Vitest unit | **Preconditions:** Mobile viewport, keyboard visible. **Actions:** Render shell, update keyboard inset. **Expected outcome:** Root has `paddingBottom: var(--keyboard-inset-bottom)`. Composer `enterkeyhint='send'`. Send button has minimum touch target. **Source of truth:** Main-branch `useKeyboardInset` behavior ported to fresh-agent. **Interactions:** Composer, shell layout. | +| 102 | **Virtualization container height adjusts when keyboard inset changes** | boundary | new | Vitest unit | **Preconditions:** Virtual list rendered, keyboard inset changes. **Actions:** Update keyboard CSS variable. **Expected outcome:** List height recalculated within available space. Scroll position preserved for visible rows. **Source of truth:** `react-window` `List` with dynamic `defaultHeight`. **Interactions:** Keyboard open/close events. | +| 103 | **Approval and question banner buttons have accessible labels and mobile touch targets** | invariant | new | Vitest unit | **Preconditions:** Snapshot with pending approvals and questions. **Actions:** Render banners at mobile width. **Expected outcome:** Each action button has `aria-label`, `min-height >= 44px` or equivalent touch target. **Source of truth:** WCAG 2.5.5 Target Size. **Interactions:** Banner components. | + +### Freshcodex session identity, titles, sidebar, settings, and projections (Task 10) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 104 | **Freshcodex pane defaults come from Codex provider settings, not Claude settings** | invariant | new | Vitest unit | **Preconditions:** `codingCli.providers.codex: { model, sandbox, permissionMode }`, `freshAgent.providers.freshcodex: { defaultEffort }`. **Actions:** Create Freshcodex pane from picker. **Expected outcome:** Pane content has `model`, `sandbox`, `permissionMode` from Codex settings, `effort` from freshAgent freshcodex settings. Not routed through `getAgentChatProviderConfig()`. **Source of truth:** Implementation plan "Freshcodex creation uses freshAgent.providers.freshcodex plus codingCli.providers.codex." **Interactions:** PanePicker, session-type-utils, settings. | +| 105 | **Freshcodex pane with Claude-only settings creates controlled createError instead of silently coercing** | boundary | new | Vitest unit | **Preconditions:** `codingCli.providers.codex.permissionMode: 'bypassPermissions'`, `freshAgent.providers.freshcodex.defaultEffort: 'max'`. **Actions:** Create Freshcodex pane. **Expected outcome:** Pane created with `createError: { code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' }`. Pane visible, not dropped or replaced with picker. **Source of truth:** Implementation plan "migrate invalid legacy Freshcodex values into a visible createError." **Interactions:** Controller error display, shell. | +| 106 | **Freshcodex sandbox, effort, and structured approval policy survive pane persistence and restore** | invariant | new | Vitest unit | **Preconditions:** Pane with `sandbox: 'workspace-write'`, `permissionMode: { granular: {...} }`, `effort: 'xhigh'`. **Actions:** Persist to localStorage, reload, rehydrate. **Expected outcome:** All three values preserved unchanged. Effort not narrowed to `'low' | 'medium' | 'high' | 'max'`. Granular approval policy not cast to string. `sandbox` not dropped. **Source of truth:** `FreshAgentRuntimeSettingsSchema`. **Interactions:** Persisted state, panesSlice hydrate. | +| 107 | **Remote tab snapshots preserve Freshcodex sandbox, Codex effort, and structured approval policy** | invariant | new | Vitest unit | **Preconditions:** Pane with Codex-shaped runtime settings. **Actions:** `collectPaneSnapshots()` for remote session tab. **Expected outcome:** Snapshot payload includes `sandbox`, `permissionMode` (preserving granular objects), `effort` (preserving `xhigh`). None narrowed to Claude types. **Source of truth:** Implementation plan "remote snapshots must not cast Freshcodex effort back to low|medium|high|max." **Interactions:** TabsView, tab registry. | +| 108 | **Freshcodex session title defaults to 'Freshcodex', then updates from first user message or thread name** | scenario | new | Vitest unit | **Preconditions:** Freshcodex session with `thread.name`, then first user message. **Actions:** Derive pane title at each state. **Expected outcome:** Initially `'Freshcodex'`. After thread name: shows name. After user message: shows message preview or name if set by user. **Source of truth:** `derivePaneTitle` with fresh-agent content. **Interactions:** Tab bar, pane title. | +| 109 | **Freshcodex history query includes all rich app-server and child-agent source kinds** | integration | new | Vitest unit | **Preconditions:** Codex rich adapter. **Actions:** Call `loadFreshcodexHistoryPage({ limit: 25 })`. **Expected outcome:** Runtime `listThreads` called with `sourceKinds: ['appServer', 'vscode', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther']`. Response normalized to `FreshAgentThreadListPageSchema` with `items`, `nextCursor`, `backwardsCursor`. **Source of truth:** Implementation plan explicit source-kinds requirement. **Interactions:** HistoryView, session directory. | +| 110 | **Freshcodex model list and capabilities come from app-server model/list and modelProvider/capabilities/read** | integration | new | Vitest unit | **Preconditions:** Rich adapter with model/capability methods. **Actions:** Call `loadFreshcodexModelOptions()`. **Expected outcome:** Models have `id`, `displayName`, `defaultReasoningEffort`. Capabilities include `supportsWebSearch`, etc. Falls back to typed runtime-unavailable error if app-server unreachable (not stale Claude defaults). **Source of truth:** Generated `ModelListResponse.ts`. **Interactions:** SettingsView model picker, capability badges. | +| 111 | **Settings types split Codex approval/effort/sandbox from Claude permission/effort to prevent accidental interchange** | invariant | new | Vitest unit | **Preconditions:** Updated `shared/settings.ts`. **Actions:** Assert `codingCli.providers.codex.permissionMode` is typed as Codex approval policy, not Claude permission mode. Assert `freshAgent.providers.freshcodex.defaultEffort` is typed as Codex effort. **Expected outcome:** TypeScript compilation fails if Claude-only value assigned to Codex field. **Source of truth:** Generated Codex runtime-setting leaf types. **Interactions:** All settings consumers. | +| 112 | **Historic Freshcodex panes attach after browser reload by sending attach context and loading the thread** | scenario | new | Vitest unit | **Preconditions:** Pane with saved `sessionId`, `initialCwd`, model/sandbox/permission/effort. **Actions:** Render pane, assert attach WS message sent. **Expected outcome:** WS message includes `cwd` and `runtimeSettings`. Adapter calls `ensureThreadLoaded` which resumes if needed. Snapshot loads successfully. **Source of truth:** Implementation plan "restored Freshcodex panes send attach context and load/resume the Codex app-server thread." **Interactions:** Controller, adapter. | + +### Reconnect, multi-client, error handling (Task 11) + +| # | Name | Type | Disposition | Harness | Details | +|---|------|------|-------------|---------|---------| +| 113 | **Two clients subscribed to the same Freshcodex thread both refresh on notification-driven invalidation** | scenario | new | Vitest unit | **Preconditions:** Two browser clients attached to same session. **Actions:** Codex emits `turn/completed` notification. **Expected outcome:** Both clients receive `freshAgent.snapshot.invalidate` event. Both refresh snapshot. Neither dropped. **Source of truth:** Implementation plan multi-client behavior. **Interactions:** WS handler, runtime manager subscriptions. | +| 114 | **Stopped Codex app-server surfaces runtime-unavailable error with retry, not cleared pane state** | boundary | new | Vitest unit | **Preconditions:** Rich runtime reports error. **Actions:** Snapshot request fails. **Expected outcome:** Controller shows `{ code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', retryable: true }` error. Pane content unchanged. Retry create available. **Source of truth:** Implementation plan error recovery. **Interactions:** Shell error display, retry button. | +| 115 | **Browser reconnect does not create duplicate turns by re-sending create for pane with in-flight request** | boundary | new | Vitest unit | **Preconditions:** Pane has `createRequestId` in flight. **Actions:** Reconnect WS, controller checks existing `createRequestId`. **Expected outcome:** Does not re-send `freshAgent.create`. Attaches existing `sessionId`. **Source of truth:** Implementation plan "do not re-send create for a pane with an in-flight request." **Interactions:** WS client reconnect, controller. | +| 116 | **Notification burst debounces snapshot refresh to one near-term REST request per session** | boundary | new | Vitest unit | **Preconditions:** 5 notifications arrive within 100ms for same session. **Actions:** Wait for debounce window. **Expected outcome:** Only 1 REST snapshot request made, not 5. All 5 events acknowledged but collapsed into single refresh. **Source of truth:** Implementation plan debounce rule. **Interactions:** Controller event handler. | +| 117 | **Action failures emit typed freshAgent.error messages instead of generic sdk errors** | invariant | new | Vitest unit | **Preconditions:** `send` action fails with lost session. **Actions:** Assert WS error message. **Expected outcome:** Message has `type: 'freshAgent.error'`, `sessionId`, `code: 'FRESH_AGENT_LOST_SESSION'`, `message`, `retryable: true`. Not a generic `sendError` or `sdk.error`. **Source of truth:** `shared/ws-protocol.ts` `freshAgent.error` message type. **Interactions:** Controller error state, shell error display. | + +--- + +## Coverage Summary + +### Covered areas + +| Area | Test count | Priority | Harness | +|------|-----------|----------|---------| +| **E2E Browser: Picker & lifecycle** | 13 tests (E2E-1) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Shell states** | 9 tests (E2E-2) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Composer I/O** | 9 tests (E2E-3) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Transcript items** | 19 tests (E2E-4) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Workspace panel** | 11 tests (E2E-5) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Approval banners** | 6 tests (E2E-6) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Question banners** | 5 tests (E2E-7) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Fork/review flow** | 6 tests (E2E-8) | **P0** | Playwright E2E + orchestration | +| **E2E Browser: Mobile viewport** | 5 tests (E2E-9) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Multi-client/reconnect** | 4 tests (E2E-10) | **P0** | Playwright E2E | +| **E2E Browser: Settings** | 6 tests (E2E-11) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Persistence** | 4 tests (E2E-12) | **P0** | Playwright E2E + screenshots | +| **E2E Browser: Real Codex runtime** | 10 tests (E2E-13) | **P0** | Playwright E2E + real Codex | +| Existing regression (merge gate) | 3 tests (1-3) | P1 | Vitest unit | +| Existing integration/scenario | 4 tests (4-7) | P2 | Vitest unit + Playwright | +| Shared contract schemas | 8 tests (8-15) | P3 | Vitest unit | +| Provider registry & routing | 8 tests (16-23) | P3 | Vitest unit | +| Codex app-server protocol | 13 tests (24-36) | P3 | Vitest unit | +| Codex normalization & adapter | 22 tests (37-58) | P3 | Vitest unit | +| Controller, shell, composer | 8 tests (59-66) | P3 | Vitest unit | +| Differential/reference | 2 tests (67-68) | P4 | Vitest unit | +| Invariants | 3 tests (69-71) | P5 | Vitest unit | +| Boundary & edge cases | 9 tests (72-80) | P6 | Vitest unit | +| Regression | 5 tests (81-85) | P7 | Vitest unit | +| Unit (pure algorithms) | 7 tests (86-92) | P8 | Vitest unit | +| Transcript paging & virtualization | 4 tests (93-96) | P3 | Vitest unit | +| Item rendering & workspace UX | 4 tests (97-100) | P3 | Vitest unit | +| Mobile keyboard & touch | 3 tests (101-103) | P3 | Vitest unit | +| Session identity & settings | 9 tests (104-112) | P3 | Vitest unit | +| Reconnect & error handling | 5 tests (113-117) | P3 | Vitest unit | + +**Total: 107 new E2E browser tests (P0) + 117 unit/integration tests (P1-P8) = 224 tests** + +Every visual state is driven through a real browser: picker entry, all 6 shell lifecycle states, compose/send with text + images + settings, all 17 transcript item kinds, workspace panel with worktrees/child threads/diffs/review/fork/tokens, all approval and question banner types, fork→sibling pane lifecycle, review start→results flow, mobile viewport with keyboard/sheet/touch, multi-client and reconnect resilience, settings dialog with real model lists, persistence and restore including legacy migration, and real Codex runtime end-to-end with a cheap model. + +### Explicitly excluded per agreed strategy + +1. **Performance benchmarking** — Not in scope beyond the virtualization "does not render every row" assertion. +2. **Freshopencode** — Remains disabled and unimplemented. No tests written for it. +3. **Claude raw terminal scrape path** — The existing raw terminal paths are separate. +4. **Electron-specific behavior** — Separate test suite. + +### Risks carried by exclusions + +- **Schema snapshot staleness**: If Codex releases a breaking CLI update (0.129.0+), the checked-in snapshot must be regenerated via `scripts/audit-codex-app-server-schema.ts`. The audit script fails when local schema diverges, so this is a controlled (not silent) risk. +- **Real Codex runtime E2E tests depend on Codex CLI availability**: E2E-13 tests require a working `codex` CLI on the test machine. CI environments without `codex` installed skip these tests gracefully using `test.skip(!codexAvailable)`. This is controlled: the gate runs these locally and in CI with Codex available. +- **Real E2E test cost**: E2E-13 tests make real Codex API calls. With `gpt-5.4-nano` ($0.20/$1.25 per MTok input/output) and `effort: minimal`, each turn costs approximately $0.005-$0.02. The full E2E-13 suite of ~10 tests costs approximately $0.10-$0.30 per full run. diff --git a/docs/plans/2026-05-09-fix-opencode-ambiguous-ownership.md b/docs/plans/2026-05-09-fix-opencode-ambiguous-ownership.md new file mode 100644 index 000000000..973597def --- /dev/null +++ b/docs/plans/2026-05-09-fix-opencode-ambiguous-ownership.md @@ -0,0 +1,529 @@ +# Plan: Fix OpenCode Ambiguous Session Ownership Warnings + +## 1. Problem Statement + +### Symptoms + +Freshell v0.7.0 (production) emits repeated WARN-level logs from `opencode-activity-tracker`: + +``` +OpenCode endpoint reported ambiguous session ownership; suppressing durable adoption. +``` + +Each warning includes `terminalId` and 2–5 `sessionIds` per occurrence. 21+ terminals affected, 500+ warnings in ~2 days. Zero terminals recovered from `ambiguous`. Warnings are not cosmetic — they suppress `turnComplete` events and `requestAssociation`, breaking session completion tracking and durable session binding. + +### Timeline + +| Date | Event | Source | +|---|---|---| +| May 6, 21:33 PT | `29dc693c` — ownership reducer introduced (19 files, +1278/-129) | `git show 29dc693c --stat` | +| May 7, 00:35 PT | `c1f76b1f` — edge case fixes; ambiguous paths unchanged | `git show c1f76b1f --stat` | +| May 9, 11:15 PT | `dist/server/coding-cli/opencode-ownership-reducer.js` compiled | `stat` on dist file | +| May 9, 11:16 PT | Server restart (PID 2383202) | `ps -p 2383202 -o lstart` | +| May 9, 11:34 PT | First warning (~18 min after restart) | Log: 18:34:47 UTC | +| May 9, 11:34–11:38 PT | 6 warnings across 4 terminals. Terminal `1cj9RGOF70F1OYf-NW4XI` grows 2→3→4 sessionIds in 29s. | Log entries | + +### Regression Explanation + +- **May 6–9, 11:15 PT**: Code was on `dev` but the running server was from the previous build — `extractBusySessionId` handled multi-busy silently. +- **May 9, 11:16 PT**: Server restart picked up the build containing the ownership reducer. +- **May 9, 11:34 PT**: After ~18 minutes of user/agent activity, OpenCode terminals spawned sub-agents. The reducer saw 2+ concurrent busy sessions and entered `ambiguous`. + +--- + +## 2. Root Cause + +### 2.1 OpenCode's Session Model is Parent-Child + +OpenCode sessions are stored in SQLite (`~/.local/share/opencode/opencode.db`). The `session` table has a `parent_id` column (nullable FK to `session.id`). **Root sessions have `parent_id = NULL`; child (sub-agent) sessions have a non-null `parent_id`.** + +```sql +-- Source: opencode.db (confirmed via sqlite3 queries) +CREATE TABLE session ( + id text PRIMARY KEY, + parent_id text, -- NULL = root; non-NULL = child sub-agent + -- ... +); +``` + +The OpenCode SDK's `Session` type confirms: +```typescript +// Source: @opencode-ai/sdk/dist/v2/gen/types.gen.d.ts L785-791 +export type Session = { id: string; parentID?: string; ... } +``` + +Sub-agent spawning is routine, not exceptional. Querying the production DB: +``` +$ sqlite3 opencode.db "SELECT COUNT(*) FROM session WHERE parent_id IS NOT NULL AND time_updated > unixepoch('now','-1 day')" +→ dozens of child sessions in the last 24 hours +``` + +Every parent session spawns 2-4 children. Example from the currently-running OpenCode process that triggered these warnings: +``` +ses_1f13ee760 → NULL (root — "Scanning for secrets") +ses_1f13ec6a6 → ses_1f13ee760 "Read all repo files (@explore subagent)" +ses_1f13eb2bd → ses_1f13ee760 "Search git history (@explore subagent)" +ses_1f13ea1a9 → ses_1f13ee760 "Search for hidden secrets (@explore subagent)" +``` + +### 2.2 `/session/status` Returns All Non-Idle Sessions Without Parent Metadata + +The HTTP endpoint returns a flat map with only a `type` field: + +```json +// Source: confirmed via curl against ports 43135, 41119, 34343 +{ "ses_root": {"type": "busy"}, "ses_child": {"type": "busy"} } +``` + +The tracker's Zod schema confirms this — `{ type: "idle"|"busy"|"retry" }` only: +```typescript +// Source: server/coding-cli/opencode-activity-tracker.ts L58-64 +const SessionStatusSchema = z.discriminatedUnion('type', [ + SessionIdleStatusSchema, // { type: "idle" } + SessionBusyStatusSchema, // { type: "busy" } + SessionRetryStatusSchema, // { type: "retry", attempt, message, next, action? } +]) +``` + +**No `parentID`, `pid`, or `cwd` in the response.** The tracker cannot distinguish root from child using the HTTP API alone. When a root and its children are all concurrently busy, the status map contains 2+ entries — the reducer interprets this as competing ownership. + +### 2.3 The Tracker Drops `session.created` Events + +Only three event types are accepted: +```typescript +// Source: server/coding-cli/opencode-activity-tracker.ts L95-99 +const KNOWN_OPENCODE_EVENT_TYPES = new Set([ + 'server.connected', + 'session.status', + 'session.idle', +]) +``` + +All other events (line 160) are dropped. The `session.created` event, which **includes `parentID` in `info`**, never reaches the reducer: +```typescript +// Source: @opencode-ai/sdk/dist/v2/gen/types.gen.d.ts L818-826 +export type EventSessionCreated = { + type: "session.created"; + properties: { sessionID: string; info: Session }; // Session has parentID +}; +``` + +### 2.4 The Sidebar Knows to Filter Children — The Tracker Doesn't + +Freshell's sidebar lister already excludes child sessions from the session list: +```typescript +// Source: server/coding-cli/providers/opencode.ts L62-76 +WHERE s.parent_id IS NULL // ← filters to root sessions only + AND s.time_archived IS NULL +``` + +The tracker is the one subsystem that doesn't apply this filter. It's not a design choice — the tracker has no mechanism to distinguish root from child. + +### 2.5 The Reducer's Multi-Busy → Ambiguous Paths + +Four code paths trigger `enterAmbiguous` when the reducer sees 2+ busy sessions: + +| Path | Lines | State Before | Trigger | When | +|---|---|---|---|---| +| **P1** | `344-361` (reduceSnapshot) | `quiet` with `knownSessionId` | Known session busy + children also busy in snapshot | Resumed terminal, sub-agents active | +| **P2** | `378-379` (reduceSnapshot) | `quiet`, no known session | 2+ busy in snapshot, no known session to prefer | Fresh terminal with sub-agents | +| **P3** | `174-186` (reduceBusy) | `knownBusy` | Different session reports `busy` via SSE | Child sub-agent starts while parent is `knownBusy` | +| **P4** | `293-311` (reduceSnapshot) | `knownBusy` | Known session missing from busy set, or 2+ busy in snapshot | Reconnection while children active | + +**P3 is the most frequent trigger:** +```typescript +// Source: server/coding-cli/opencode-ownership-reducer.ts L174-186 +if (state.kind === 'knownBusy') { + if (state.sessionId === observation.sessionId) { + return { ... /* stay knownBusy */ } + } + // Different session = enter ambiguous + return enterAmbiguous({ + knownSessionId: state.sessionId, + blockedSessionIds: [state.sessionId, observation.sessionId], + at: observation.at, + }) +} +``` + +A child sub-agent's `session.status { type: "busy" }` SSE event causes `knownBusy → ambiguous`. **The child is not competing for the terminal — it is work delegated by the parent.** + +### 2.6 The UNION Accumulation Bug Prevents Recovery + +Even with correct child filtering, a defective UNION at line 281 traps stale sessions: +```typescript +// Source: server/coding-cli/opencode-ownership-reducer.ts L281 +const blockedSessionIds = uniqueSorted([...state.blockedSessionIds, ...busySessionIds]) +``` + +This is a pure UNION — sessions never pruned. If SSE disconnects before idle events arrive for completed children, and a reconnection snapshot shows only the parent as busy, the UNION re-adds completed children from `state.blockedSessionIds`. + +**Confirmed via reproducer test (`/tmp/rca-reproduce.ts`):** +- Initial: `blocked = [A, B, C]` +- Disconnect (child B and C complete — idle events lost) +- Reconnect snapshot: `busy = [A, D]` +- Result: `blocked = [A, B, C, D]` — B and C permanently trapped + +### 2.7 Why Pre-0.7.0 Had No Warnings + +The old `extractBusySessionId` had no state machine and no ambiguity detection: +```typescript +// Source: git show 23f5ca38^:server/coding-cli/opencode-activity-tracker.ts +function extractBusySessionId(snapshot, currentSessionId?) { + const busySessionIds = Object.entries(snapshot) + .filter(([, status]) => status.type !== 'idle') + .map(([id]) => id).sort() + if (currentSessionId && busySessionIds.includes(currentSessionId)) return currentSessionId + return busySessionIds[0] // deterministic pick, no warning +} +``` + +### 2.8 `--session` Flag Cannot Prevent Multi-Busy + +The `--session` flag requires an EXISTING session ID and selects which session the TUI connects to. It does not prevent the server from having other active sessions (children). And it cannot be used for fresh terminals — arbitrary IDs are rejected with "Session not found." + +--- + +## 3. Solution + +### Design Principle + +**Child sessions are not competing owners. Only root sessions can own a terminal.** The tracker should use OpenCode's actual data model to identify children and filter them out before they reach the ownership reducer. The reducer never sees child sessions — it only sees root sessions. + +### 3.1 Track Child Sessions With a Per-Monitor Set + +Add `session.created` to `KNOWN_OPENCODE_EVENT_TYPES` and add its Zod schema: + +```typescript +// server/coding-cli/opencode-activity-tracker.ts +const SessionCreatedEventSchema = z.object({ + type: z.literal('session.created'), + properties: z.object({ + sessionID: z.string().min(1), + info: z.object({ + parentID: z.string().nullable().optional(), // nullable — SQL NULL serializes to null + }).passthrough().optional(), // info may be missing entirely + }).passthrough(), +}).passthrough() +``` + +Add to `KNOWN_OPENCODE_EVENT_TYPES`, add to `OpencodeEventSchema` union, and add a handler in `handleOpencodeEvent`. **The existing handler is NOT an if/else chain** — it has one `session.idle` branch and a fall-through that assumes `session.status` (tracker L411–437). The handler must be restructured into explicit branches: + +```typescript +// OpencodeEventSchema union — add SessionCreatedEventSchema: +const OpencodeEventSchema = z.discriminatedUnion('type', [ + ServerConnectedEventSchema, + SessionStatusEventSchema, + SessionIdleEventSchema, + SessionCreatedEventSchema, // NEW +]) + +// handleOpencodeEvent — restructured with explicit branches: +private handleOpencodeEvent( + monitor: MonitorState, + cycleId: number, + streamId: number, + event: Exclude<z.infer<typeof OpencodeEventSchema>, { type: 'server.connected' }>, +): void { + // NEW: register child sessions from session.created events + if (event.type === 'session.created') { + if (event.properties.info?.parentID != null) { + this.registerChildSession(monitor.terminalId, event.properties.sessionID) + } + return + } + + if (event.type === 'session.idle') { + const children = this.childSessionIds.get(monitor.terminalId) + if (children?.has(event.properties.sessionID)) { + children.delete(event.properties.sessionID) + return + } + this.observe(monitor, { + kind: 'sse', cycleId, streamId, + sessionId: event.properties.sessionID, + status: 'idle', + at: this.now(), + }) + return + } + + // session.status — filter children, then observe + const children = this.childSessionIds.get(monitor.terminalId) + if (children?.has(event.properties.sessionID)) { + return // child session — do not observe (cleanup happens on session.idle) + } + this.observe(monitor, { + kind: 'sse', cycleId, streamId, + sessionId: event.properties.sessionID, + status: event.properties.status.type, + at: this.now(), + }) +} +``` + +The `session.idle` branch filters children and cleans up the tracking set. The `session.status` branch filters children but does NOT clean up — cleanup happens only via `session.idle` to avoid double-delivery issues if OpenCode emits both event types for the same session. The `session.created` branch only registers children — it does NOT trigger a re-evaluation or snapshot refresh. + +```typescript +// server/coding-cli/opencode-activity-tracker.ts (new method) +private childSessionIds = new Map<string, Set<string>>() + +private registerChildSession(terminalId: string, sessionID: string): void { + let children = this.childSessionIds.get(terminalId) + if (!children) { + children = new Set() + this.childSessionIds.set(terminalId, children) + } + children.add(sessionID) +} +``` + +**Ordering race (P3):** If `session.status { busy }` arrives before `session.created` for the same child, the reducer enters `ambiguous`. When `session.created` arrives, the child is registered — but the state stays `ambiguous` until the next snapshot. Recovery happens on reconnection (when `resyncFromDb` runs inside `refreshSnapshot`). If `session.created` is never emitted by OpenCode, this SSE path is dead code — the DB path handles everything. + +### 3.2 Identify Children From DB Inside `refreshSnapshot` + +The entire complexity of `seedChildSessions`, `resyncChildSessions`, and `extractKnownSessionIds` is eliminated. Instead, `refreshSnapshot` (which is already `async`) queries the DB directly after fetching the HTTP status map. This is the **primary child detection mechanism** — SSE `session.created` events are a best-effort optimization. + +```typescript +// server/coding-cli/opencode-activity-tracker.ts — refreshSnapshot (modified) +private async refreshSnapshot( + monitor: MonitorState, + cycleId: number, + streamId: number, + signal: AbortSignal, +): Promise<void> { + const response = await this.fetchImpl(this.buildUrl(monitor.endpoint, '/session/status'), { + signal, + }) + if (!response.ok) { + throw new Error(`OpenCode session status request failed with ${response.status}.`) + } + + const parsed = SessionStatusMapSchema.safeParse(await response.json()) + if (!parsed.success) { + throw new Error('OpenCode session status response did not match the expected schema.') + } + + // NEW: identify children from the DB using the active session IDs + await this.resyncFromDb(monitor, Object.keys(parsed.data)) + + // Filter children from the status map + const children = this.childSessionIds.get(monitor.terminalId) + const filteredStatuses: Record<string, z.infer<typeof SessionStatusSchema>> = {} + for (const [sessionId, status] of Object.entries(parsed.data)) { + if (!children?.has(sessionId)) { + filteredStatuses[sessionId] = status + } + } + + this.observe(monitor, { + kind: 'snapshot', cycleId, streamId, + statuses: filteredStatuses, // children excluded + at: this.now(), + }) +} +``` + +```typescript +// NEW: resyncFromDb — query DB for children of active sessions, register them +private async resyncFromDb(monitor: MonitorState, activeSessionIds: string[]): Promise<void> { + if (!this.dbPath || activeSessionIds.length === 0) return + let db: InstanceType<typeof import('node:sqlite').DatabaseSync> | undefined + try { + const sqlite = await import('node:sqlite') + db = new sqlite.DatabaseSync(this.dbPath, { readOnly: true }) + const placeholders = activeSessionIds.map(() => '?').join(',') + const rows = db.prepare( + `SELECT id, parent_id FROM session WHERE id IN (${placeholders}) AND parent_id IS NOT NULL` + ).all(...activeSessionIds) as Array<{ id: string; parent_id: string }> + for (const row of rows) { + this.registerChildSession(monitor.terminalId, row.id) + } + } catch { + // DB unavailable or node:sqlite not supported — children unfiltered in this snapshot + } finally { + db?.close() + } +} +``` + +**Why this works for all scenarios:** +- **Fresh terminal (P2):** The first snapshot contains all busy sessions (root + children). `resyncFromDb` queries the DB for all of them, finds which have `parent_id IS NOT NULL`, registers them as children, and filters them from the snapshot. The reducer sees only the root session. +- **Ordering race (P3):** If `session.status { busy }` arrives before `session.created`, the reducer enters `ambiguous`. On the next snapshot (on reconnection), `resyncFromDb` catches the child via DB query and filters it. Recovery happens on reconnection — not mid-stream. +- **Historical sessions:** The query `WHERE id IN (...)` only matches sessions that are currently in the status map, so historical sessions are never returned. + +### 3.3 Reconnection Resync + +`refreshSnapshot` is called once per stream connection (on `server.connected`). When the stream disconnects and `runMonitor` reconnects, `refreshSnapshot` fires again — automatically re-querying the DB. No separate resync mechanism is needed. + +**Important: `refreshSnapshot` is NOT periodic.** It only fires on initial connection and reconnection. Children spawned while the stream is open are detected via `session.created` SSE events (if OpenCode emits them — unverified). If OpenCode does not emit `session.created`, children accumulate undetected until the next reconnection. This is a known limitation — the DB resync catches them eventually. + +**Startup seeding from `trackTerminal`:** For resumed terminals with a `sessionId`, kick off an async DB seed so children are known before the first snapshot: + +```typescript +// In trackTerminal, after creating the monitor: +if (input.sessionId) { + void this.seedFromDb(monitor, [input.sessionId]) +} + +private async seedFromDb(monitor: MonitorState, parentIds: string[]): Promise<void> { + if (!this.dbPath || parentIds.length === 0) return + let db: InstanceType<typeof import('node:sqlite').DatabaseSync> | undefined + try { + const sqlite = await import('node:sqlite') + db = new sqlite.DatabaseSync(this.dbPath, { readOnly: true }) + const placeholders = parentIds.map(() => '?').join(',') + const rows = db.prepare( + `SELECT id FROM session WHERE parent_id IN (${placeholders}) AND time_archived IS NULL` + ).all(...parentIds) as Array<{ id: string }> + for (const row of rows) { + this.registerChildSession(monitor.terminalId, row.id) + } + } catch { + // DB unavailable — first snapshot will catch via resyncFromDb + } finally { + db?.close() + } +} +``` + +**`dbPath` derivation:** The tracker derives the DB path internally using the same logic as `OpencodeProvider.getDatabasePath()` — `defaultOpencodeDataHome()` + `'opencode.db'`. Export `defaultOpencodeDataHome` from `providers/opencode.ts` and import it in the tracker: + +```typescript +// providers/opencode.ts — export the existing function: +export function defaultOpencodeDataHome(): string { ... } + +// opencode-activity-tracker.ts — import and use: +import { defaultOpencodeDataHome } from './providers/opencode.js' + +// In constructor: +private readonly dbPath?: string +constructor(input: { + // ... existing fields + homeDir?: string // NEW — defaults to defaultOpencodeDataHome() +} = {}) { + // ... existing init + const homeDir = input.homeDir ?? defaultOpencodeDataHome() + this.dbPath = path.join(homeDir, 'opencode.db') +} +``` + +`path` and `os` are no longer needed in the tracker — the path logic lives in the provider. The `homeDir` parameter allows tests to inject a custom path; production callers omit it to use the default. + +### 3.5 Fix the UNION Accumulation (Defense-in-Depth) + +```typescript +// server/coding-cli/opencode-ownership-reducer.ts L281 +// OLD: +const blockedSessionIds = uniqueSorted([...state.blockedSessionIds, ...busySessionIds]) +// NEW: the snapshot is authoritative for current state. +const blockedSessionIds = uniqueSorted(busySessionIds) +``` + +The snapshot represents the complete set of busy sessions at time T. If a session is not in the snapshot, it should not be in `blockedSessionIds`. The UNION was an unnecessary persistence mechanism — the snapshot already contains all relevant state. + +**Side effect (correct behavior):** Sessions that completed during an SSE disconnect and are no longer in the snapshot are removed from `blockedSessionIds`. Their lost idle events are irrelevant — the snapshot is authoritative for current state. If an SSE `busy` arrives for a genuine new root session between snapshot request and processing, the snapshot will temporarily drop it; the next SSE re-adds it. This may cause a brief flicker (one extra `warnAmbiguous` then recovery) — acceptable because it's self-correcting and rare. + +### 3.6 Impact On Each Ambiguous Path + +| Path | Before Fix | After Fix | +|---|---|---| +| **P1** (Quiet, knownSessionId, snapshot with root+children) | Enters `ambiguous` | Stays `knownBusy` — children filtered from snapshot via `resyncFromDb` | +| **P2** (Quiet, fresh terminal, snapshot with children) | Enters `ambiguous` | Candidates the single root session — `resyncFromDb` identifies children from DB using the snapshot's active session IDs | +| **P3** (knownBusy + SSE child busy) | Enters `ambiguous` | If `session.created` fires first: child filtered at event boundary, state stays `knownBusy`. If `session.status` fires first: enters `ambiguous`, recovers on next reconnection via `resyncFromDb` | +| **P4** (knownBusy snapshot with children) | Enters `ambiguous` | Stays `knownBusy` — children filtered from snapshot | +| **UNION** (reconnect snapshot) | Stale sessions trapped | Blocked set recomputed from snapshot; stale sessions dropped | + +### 3.7 Edge Cases Addressed + +**A. Ordering race (P3):** If `session.status { busy }` arrives before `session.created` for the same child, the reducer enters `ambiguous`. The child is registered when `session.created` arrives, but `refreshSnapshot` is NOT periodic — it only fires on connection/reconnection. Recovery happens on the next reconnection, when `resyncFromDb` identifies the child via DB query and filters it from the snapshot. If `session.created` is never emitted, the DB path catches children on every reconnection. + +**B. Deep sub-agent nesting (grandchildren):** `resyncFromDb` queries `WHERE id IN (activeSessionIds) AND parent_id IS NOT NULL` — it catches any child that appears in the status map, regardless of nesting depth. Grandchildren are identified as children (they have a non-null `parent_id`). DB seeding from `trackTerminal` uses `WHERE parent_id IN (knownSessionIds)` which catches direct children of the resumed session. + +**C. Root idle + children still busy:** If the root goes idle via SSE while children remain busy, `reduceIdle` for `knownBusy` (L230-241) uses `sameSessionStream` gating — it only processes idle for the exact session that went busy. Children's events are filtered at the event boundary before reaching the reducer (Section 3.1). The snapshot path (L293-311) only fires `activityRemove`, not `turnComplete` — `turnComplete` is SSE-only. + +**D. Child session tracking memory:** `childSessionIds` map grows with each sub-agent spawn. Cleaned up on SSE idle for children (Section 3.1 handler) and on `untrackTerminal` (add `this.childSessionIds.delete(input.terminalId)` to the existing cleanup in `untrackTerminal` at tracker L234–248). Memory bounded by active children per terminal (~4–8), ~21 terminals = negligible. + +**E. DB unavailable (Node < 22.5):** `resyncFromDb` catches errors silently. Children are unfiltered in the snapshot — behavior regresses to current state. SSE `session.created` path (if available) still handles real-time detection. + +**F. `ambiguous` with single known session after filtering:** If child filtering reduces the busy set to exactly one session (the `knownSessionId`), the reducer should transition from `ambiguous` → `knownBusy`. Currently the reducer stays `ambiguous` even when the field clears. Add a transition in `reduceSnapshot`'s `ambiguous` branch (reducer L274-290): + +```typescript +// In reduceSnapshot, ambiguous branch, after recomputing blockedSessionIds: +if (blockedSessionIds.length === 1 && blockedSessionIds[0] === state.knownSessionId) { + // Field cleared — single known owner. Resume normal tracking. + return { + state: { kind: 'knownBusy', sessionId: state.knownSessionId, ... }, + actions: [{ kind: 'activityUpsert', sessionId: state.knownSessionId, at: observation.at }], + } +} +``` + +### 3.8 Files Changed + +| File | Changes | Approx Lines | +|---|---|---| +| `server/coding-cli/opencode-activity-tracker.ts` | Add `childSessionIds` map, `registerChildSession`, `seedFromDb`, `resyncFromDb`. Add `session.created` event schema + handler. Restructure `handleOpencodeEvent` into explicit branches. Filter children from `session.status`/`session.idle` paths. Modify `refreshSnapshot` to call `resyncFromDb` and filter children. Add `untrackTerminal` cleanup for `childSessionIds`. Add `homeDir` constructor param + `dbPath` derivation. Import `defaultOpencodeDataHome` from provider. | ~120 | +| `server/coding-cli/providers/opencode.ts` | Export `defaultOpencodeDataHome` function (already exists, just add `export`). | ~1 | +| `server/coding-cli/opencode-ownership-reducer.ts` | Fix UNION at L281: recompute `blockedSessionIds` from `busySessionIds`. Add `ambiguous` → `knownBusy` transition when filtered snapshot shows single known session. | ~15 | +| `test/unit/server/coding-cli/opencode-ownership-reducer.test.ts` | Tests: UNION recomputation drops stale sessions, `ambiguous` → `knownBusy` transition when field clears. | ~40 | +| `test/unit/server/coding-cli/opencode-activity-tracker.test.ts` | Tests: `session.created` child registration, SSE child filtering, snapshot child filtering via `resyncFromDb`, DB seeding success + failure modes, DB-only fallback path, `untrackTerminal` cleanup, `handleOpencodeEvent` restructuring. | ~150 | + +--- + +## 4. Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---|---| +| `session.created` never emitted by OpenCode v1.14.44 | Medium | SSE child tracking path is dead code | **DB resync inside `refreshSnapshot` is the primary mechanism.** Children are identified on every snapshot (connection + reconnection). SSE `session.created` is an unverified optimization for mid-stream detection. | +| `session.created` emitted with different schema than SDK types | Medium | Zod validation fails, logged as warning per event | `parseOpencodeEvent` catches schema mismatches and logs a warning (tracker L398–404). `.passthrough()` tolerates extra fields. `parentID: z.string().nullable().optional()` accepts both `null` and `undefined`. If the `info` key itself is missing, `.optional()` on `info` prevents rejection. | +| DB unavailable (Node < 22.5) | Low | Children unfiltered in snapshot | `resyncFromDb` catches errors silently. Behavior regresses to current state — not worse than today. | +| Deep nesting → grandchildren not caught by DB seed | Low | Grandchild appears as busy in snapshot, briefly seen as root | `resyncFromDb` queries `WHERE id IN (activeSessionIds)` — catches any child in the status map regardless of nesting depth. | +| Two genuine root sessions concurrently busy | Very Low | `enterAmbiguous` fires correctly | This IS legitimate ambiguous detection. The warning should fire. | +| `ambiguous` stuck after child filtering clears the field | Low | Terminal stays `ambiguous` with single known owner | Fixed: added `ambiguous` → `knownBusy` transition when `blockedSessionIds` reduces to `[knownSessionId]`. | +| Children spawned mid-stream with no `session.created` and no reconnection | Medium | Children accumulate undetected until reconnection | Known limitation. `refreshSnapshot` is not periodic. OpenCode streams may stay open for hours. Accept this as a partial fix — the UNION fix and `ambiguous` → `knownBusy` transition mitigate the impact. | + +--- + +## 5. Alternatives Rejected + +| Alternative | Why Rejected | +|---|---| +| Tolerate multi-busy in reducer (no child awareness) | Treats symptom. Fresh-terminal case still broken. Couples reducer to a heuristic. | +| `--session` constraint for fresh terminals | `--session` requires EXISTING session. Fresh terminals have none. | +| `GET /session/status?root=true` | Unverified API parameter. If it exists, it replaces child tracking entirely — but must be tested with OpenCode v1.14.44 first. Can be adopted later as simplification. | +| Redesign for multi-owner tracking | Over-engineered. Single-owner model is correct. Bug is root/child distinction, not model error. | + +--- + +## 6. Verification + +### 6.1 Test Reproducer + +A test script confirmed all four ambiguous-entry paths (P1-P4) and the UNION bug in the current code. After the fix, the same scenarios should produce: + +| Scenario | Expected After Fix | +|---|---| +| P1: Resumed terminal, root + 2 children busy in snapshot | Stays `knownBusy` (1 root after filtering) | +| P2: Fresh terminal, root + 3 children busy in snapshot | Enters `candidate` for root (1 root after filtering) | +| P3: `knownBusy` + SSE child busy | Stays `knownBusy` (child filtered at event boundary) | +| P4: `knownBusy` snapshot with 2 children | Stays `knownBusy` (children filtered) | +| UNION: reconnect after disconnect, children completed | Blocked set = current snapshot only; stale children dropped | + +### 6.2 Acceptance Criteria + +1. All existing ownership reducer tests pass unchanged (no behavior change for single-session scenarios). +2. New tests pass: child filtering via `session.created` SSE, child filtering from snapshot via `resyncFromDb`, UNION recomputation drops stale sessions, `ambiguous` → `knownBusy` transition when field clears. +3. DB resync tests: `resyncFromDb` identifies children from active session IDs, `node:sqlite` unavailable (falls through silently), DB file missing (falls through silently), empty results (no effect), empty active IDs (early return), `db.close()` called even on query failure. +4. DB-only fallback test: with `session.created` SSE events disabled, children are still detected via `resyncFromDb` inside `refreshSnapshot` — no `warnAmbiguous` emitted. +5. `untrackTerminal` test: `childSessionIds` map is cleaned up when terminal is untracked. +6. `warnAmbiguous` is never emitted when the only extra sessions are children with known `parentID`. +7. `warnAmbiguous` IS still emitted when two genuinely independent root sessions are concurrently busy. +8. `handleOpencodeEvent` test: `session.created` events with `parentID` register children; `session.created` events without `parentID` are ignored; `session.status` and `session.idle` events for known children are filtered out; `session.status` and `session.idle` events for non-children pass through to reducer. + +### 6.3 Production Validation + +1. Deploy to dev build (with user approval for restart). +2. Monitor for `warnAmbiguous` messages. Expect none caused by parent-child scenarios. +3. If any `warnAmbiguous` fires, capture the raw `/session/status` response and `blockedSessionIds` for root-cause analysis of remaining edge cases. +4. Verify `turnComplete` and `requestAssociation` events resume flowing for previously-affected terminals. diff --git a/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md b/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md new file mode 100644 index 000000000..c2fed122e --- /dev/null +++ b/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md @@ -0,0 +1,1995 @@ +# Freshcodex Full Suite Stabilization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation` pass the known Freshcodex/fresh-agent full-suite blockers by fixing the underlying pane identity, migration, settings, recovery, and legacy harness contracts. + +**Architecture:** `fresh-agent` is the canonical steady-state pane kind for rich Claude/Codex panes. Legacy `agent-chat` remains a compatibility input and a legacy component test target, but all production pane creation, remote rehydration, persistence, restore, and reducer ingress paths normalize it into `fresh-agent` while preserving portable durable identity separately from same-server runtime handles. Provider-specific settings stay provider-specific: Claude-backed panes use `modelSelection` and opaque effort strings, Codex panes use runtime `model` / `sandbox` / Codex settings, and neither provider inherits stale fields from the other. + +**Tech Stack:** React 18, Redux Toolkit, Vitest, Testing Library, coordinated Freshell test scripts, TypeScript/NodeNext, shared Zod-backed settings/session contracts. + +--- + +## Workspace And Base-Branch Invariants + +This worktree is `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation` on branch `freshcodex-contract-foundation`. + +The user explicitly chose `origin/dev` as the integration base for this branch. Do not rebase or merge this worktree onto `origin/main` during execution, even if generic trycycle workflow text says to do so. If the implementation agent needs to resync before or during execution, use: + +```bash +git -C /home/user/code/freshell/.worktrees/freshcodex-contract-foundation fetch origin dev +git -C /home/user/code/freshell/.worktrees/freshcodex-contract-foundation rebase origin/dev +``` + +If the branch is already based on current `origin/dev`, do not create extra sync commits. Final diff and handoff checks should compare against `origin/dev`, not `origin/main`. + +## Strategy Gate + +The known failures are not independent one-line expectation drifts. They show that the branch has not made the `agent-chat` to `fresh-agent` cutover explicit at every ingress boundary. + +Implement these contracts rather than one-off patches: + +- `fresh-agent` is the canonical production pane kind for rich Claude/Codex panes. Legacy `agent-chat` records are compatibility input only and are normalized at reducer, persistence, localStorage, cross-tab, tab-registry, remote rehydration, and session-opening boundaries. +- Treat identity as a three-part contract: + - `sessionRef` is the only portable durable identity and is the only identity published across devices or persisted as a restore target. + - `sessionId`, `resumeSessionId`, and `serverInstanceId` are same-server/runtime handles. They can be used for live same-server attach/resume, but must be stripped from persisted/cross-server payloads unless the source server matches. + - `restoreError` is an explicit durable-restore failure, not a lifecycle status. It suppresses automatic create and is rendered through a user-facing reason mapper because `RestoreError` currently has `{ code, reason }`, not a `message` field. +- Apply the identity contract by boundary, not by individual field names: + - Local reducer and same-server live UI state may retain runtime handles for the current process while a durable identity is still being discovered. + - Durable cross-server publication, tab-registry snapshots, and remote/cross-server copies must not retain `sessionId`, `resumeSessionId`, or `serverInstanceId`; they keep only `sessionRef` or an explicit `restoreError`. + - Same-server localStorage/cross-tab payloads may retain runtime handles only when they are tagged with the current `serverInstanceId` and no durable `sessionRef` exists yet. Once `sessionRef` exists, persisted/cross-tab writeback strips runtime handles and keeps `sessionRef`. + - `ui.layout.sync` is a live same-server signal, not durable storage. It must advertise canonical `fresh-agent.sessionRef` when available and may include same-server runtime handles for runtime-only Claude panes so server-side open-session tracking does not lose live sessions before durable metadata arrives. + - A pane must not contain both a valid `sessionRef` and `restoreError`. Creating or discovering a valid durable `sessionRef` clears stale `restoreError`; validation rejects persisted/cross-tab payloads that contain both. +- Portable identity rules apply symmetrically at every boundary that publishes, stores, opens, copies, validates, or creates rich-agent panes: tab-registry snapshots, tab fallback identity, sidebar fallback rows, session-opening helpers, pane reducers, pane-tree validation, persisted-state parsers, persist writeback, localStorage migration, remote rehydration, and fresh-agent create/recovery. +- Named Claude resume aliases are not portable durable identities. A named alias may remain a same-server `resumeSessionId` for live/local fallback, but it must not become `sessionRef` and must not automatically become `restoreError` in same-server reducer paths. Remote/cross-server copies with only a named alias must receive `restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' }`. +- Claude durable identity must be based on trusted durable metadata, not on a UUID-only helper. A value from `sessionRef`, `cliSessionId`, or `timelineSessionId` is a candidate portable Claude identity; a value from bare `resumeSessionId` is portable only if it satisfies the shared canonical Claude durable-ID predicate. Align `shared/session-contract.ts` and `src/lib/claude-session-id.ts` so tests do not depend on contradictory grammars. +- `freshAgent.created` must not blindly persist the runtime `sessionId` as a Claude `sessionRef`. Codex-created thread ids are durable and should be returned/persisted as `sessionRef: { provider: 'codex', sessionId }`; Claude-created sessions should persist `sessionRef` only when the server/adaptor has a trusted canonical durable id from SDK history/timeline metadata. If the server cannot prove a Claude durable id at create time, keep the runtime `sessionId` as a same-server handle only. +- `freshAgent.created` idempotency caches and reconnect replay responses must preserve the same durable `sessionRef` as the original create result. A duplicate create request must not replay only the runtime `sessionId` and thereby lose portable identity. +- Multiple create locators must be validated before any precedence rule is applied. A provider mismatch is always an error. Conflicting durable ids are an error. Codex thread IDs are durable, so `sessionRef: { provider: 'codex', sessionId: A }` plus `resumeSessionId: B` is a conflict when `A !== B`; do not treat Codex `resumeSessionId` as a non-canonical alias. For Claude only, a non-canonical same-server `resumeSessionId` may coexist with a matching-provider `sessionRef` for live attach, but persistence keeps `sessionRef` and attach uses the runtime handle only for the current server. +- Remote copied tabs containing any rich-agent pane should have `mode: 'shell'`; do not leave copied `fresh-agent` tabs classified as terminal/CLI `claude`. Whole-tab copy mode must be derived from sanitized content across the pane tree, not only from the raw first pane, and `openPaneInNewTab()` must derive mode from the sanitized clicked pane. +- Fresh-agent create messages must carry `sessionRef`, Claude `modelSelection`, and opaque Claude effort strings. Runtime adapters validate provider-specific fields; shared WS schemas and persisted pane validators must not reject valid Claude values such as `turbo`. +- The Claude fresh-agent adapter owns resolution of transported `modelSelection` into the SDK `model` value. The client should not pre-resolve Claude model aliases before sending `freshAgent.create`. +- Codex panes keep Codex runtime fields (`model`, `sandbox`, Codex effort/settings) and must not gain Claude-shaped `modelSelection` from migration helpers. Claude-backed panes migrate legacy `model` to `modelSelection` and then remove stale `model` at every canonicalization boundary, including new-pane creation. +- Settings API patches must not contain own properties with `undefined` at any depth. Clear operations use explicit `null` sentinels where the API supports clearing. This must be proven through both thunk tests and `/api/settings` route integration tests. +- Storage migration must be idempotent for users who already ran the broken branch once. Bump the local storage version and run a targeted v2-key repair for stamped clients. +- `FreshAgentView` async effects must use targeted merges or fresh refs. `freshAgent.created`, create failure, snapshot refresh, retry, and lost-session recovery must not overwrite newer pane fields from captured stale `paneContent`. +- Persisted/cross-tab fresh-agent payloads should strip same-server `sessionId` and `resumeSessionId` once durable `sessionRef` exists, and should always strip them for remote/cross-server payloads. A restored pane with only `sessionRef` must reattach/resume through `freshAgent.create` using that `sessionRef`; a restored pane with neither `sessionRef` nor trusted same-server handles must display `restoreError` and must not auto-create an unrelated new session. +- Legacy `AgentChatView` tests may mount the legacy component directly, but test wrappers must remain faithful to settings/retry updates after reducer canonicalization. The harness must keep the component mounted without freezing the prop so stale settings cannot hide the behavior being tested. +- Plan snippets must use fixture model identifiers, not real current model names. If an implementation task needs to discuss or assert a real current provider model name, the executor must first perform and record the user's required current-model lookup; this stabilization plan should not require that lookup because it only tests pass-through and migration behavior. +- Treat this as a boundary-complete identity stabilization, not as a list of isolated red tests. The same canonical rich-agent pane contract must be applied at every production ingress and replay surface: reducer initialization, persisted-state parsing, localStorage migration, cross-tab sync, no-layout `TabContent` restore, tab open/reopen helpers, tab-registry open and retained-closed publication, server-side layout sync/schema/store ingress, `/api/tabs` orchestration, MCP tab creation, UI command replay, pane activity grouping, WebSocket reliable create replay, and server/client Claude durable-ID predicates. +- There must be exactly one Claude durable-ID grammar across shared, client, and server code. `shared/session-contract.ts` should own the predicate; both `src/lib/claude-session-id.ts` and `server/claude-session-id.ts` should delegate to it or expose proven-equivalent behavior with tests that run both sides. +- Runtime handles restored from localStorage before WebSocket `ready` are provisional. Once the current `serverInstanceId` is known, stale runtime-only fresh-agent panes must reconcile to either a same-server attach, a durable `sessionRef` resume, or a user-visible `restoreError`; they must not silently auto-create unrelated sessions. +- Reliable create replay is part of the identity contract. `freshAgent.create` payloads replayed by `src/lib/ws-client.ts` must preserve the full locator/settings payload, including `sessionRef`, Claude `modelSelection`, opaque effort, Codex runtime settings, and any other provider-owned create fields. +- Settings normalization has three independent boundaries: outgoing thunk patches, server route/config-store merging, and reducer ingestion after hydration or optimistic preview. All three must canonicalize aliases, prune own `undefined` values, preserve explicit clear sentinels, and expose resolved state consistently to UI selectors. + +Do not weaken, delete, or dilute valid tests to obtain green. When a test is obsolete, replace it with a stronger assertion for the accepted canonical contract. + +## 2026-05-16 PR Review Corrections + +The PR review found four valid closure gaps that belong in this stabilization plan before the branch can be treated as complete: + +- Production bootstrap must instantiate the Fresh Agent runtime manager, register Claude and Codex adapters, mount the REST router, and pass the same runtime manager into `WsHandler`. Unit tests that inject `freshAgentRuntimeManager` are not enough; `server/index.ts` must have direct production-wiring proof. +- Fresh-agent create idempotency caches must be bounded by lifecycle. Duplicate `freshAgent.create` request ids may replay while a session is live, but `createdFreshAgentByRequestId` and `freshAgentCreateLocks` must be cleared when the session is killed and when the WebSocket handler shuts down. +- The legacy `CodexTerminalSidecar` / `CodexDurableRolloutTracker` polling implementation must not coexist with the current launch-planner plus remote-proxy durability path. Keeping both creates a stale second design that contradicts the event-driven promotion contract. +- `planCodexLaunchWithRetry()` needs direct unit coverage for transient retry, configuration-error short-circuit, and exhausted non-`Error` failures. + +Focused proof added for these corrections: + +- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/fresh-agent/production-wiring.test.ts` +- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/ws-handler-fresh-agent.test.ts` +- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/coding-cli/codex-app-server/legacy-sidecar-dead-code.test.ts` +- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/coding-cli/codex-app-server/launch-retry.test.ts` + +## Boundary Closure Matrix + +This is the matrix-first closure artifact for the plan. Implementation is not complete because a file is mentioned elsewhere in the plan; it is complete only when every boundary below has a production owner, one canonical enforcement point, and direct proof. If implementation discovers a new rich-agent identity boundary, add or update a row here before patching code. + +Every row uses the same identity vocabulary: + +- Portable durable identity means `sessionRef` only. +- Current-server runtime handles mean `sessionId`, `resumeSessionId`, and `serverInstanceId`. +- Remote, cross-server, tab-registry, and durable persisted payloads must not publish current-server runtime handles when a valid `sessionRef` exists. +- Same-server local UI state may keep runtime handles only while they are tagged with the current `serverInstanceId` and no durable `sessionRef` is available yet. +- A pane may have a valid `sessionRef` or a `restoreError`, but never both after canonicalization. +- Claude-backed panes use `modelSelection`; Codex-backed panes use runtime `model` / `sandbox` fields. + +Matrix rows: + +1. Shared identity grammar and canonicalization policy. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts`. + - Inputs: legacy `agent-chat` content, native `fresh-agent` content, create locators, restored tab identity, tab-registry snapshots, and server layout payloads. + - Enforcement: one shared Claude durable-ID predicate; one context-aware rich-agent canonicalizer for local reducer, local persistence, cross-tab, remote publication, server layout, and create/open contexts. Bare named Claude aliases remain same-server aliases locally, never portable `sessionRef`. Remote/cross-server payloads with only a nonportable alias receive `restoreError`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/session-contract.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/claude-session-id.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts`, plus boundary tests that import the shared canonicalizer instead of duplicating string checks. + +2. Reducer ingress and pane update ingress. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts`. + - Inputs: `initLayout`, `updatePaneContent`, `mergePaneContent`, split/new-pane content, restored layouts, and any dispatched rich-agent pane content. + - Enforcement: every reducer path that writes pane content runs the same canonicalizer. Legacy `agent-chat` becomes canonical `fresh-agent`; valid durable IDs become `sessionRef`; named aliases remain local `resumeSessionId` only; Claude stale `model` is migrated to `modelSelection` and removed; Codex `model` is preserved. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts` must cover init, update, and merge paths so the plan cannot repeat the previous gap where only `initLayout` was canonicalized. + +3. Persisted-state parsing and hydration. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts`. + - Inputs: `freshell.layout.v3`, legacy panes/tabs payloads, storage-migration output, cross-tab storage event payloads, and raw hydrated pane trees. + - Enforcement: parsed rich-agent content leaves this boundary already canonical. Legacy `agent-chat` is not returned to production callers; Claude stale `model` cannot survive through alternate parse paths; Codex runtime `model` cannot gain Claude `modelSelection`; runtime handles restored before WebSocket `ready` remain provisional. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` must exercise `parsePersistedLayoutRaw()`, `parsePersistedPanesRaw()`, and hydrated reducer ingestion, not only the highest-level load helper. + +4. Persisted and cross-tab writeback stripping. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`. + - Inputs: current Redux pane state being written to localStorage or same-browser cross-tab sync. + - Enforcement: keep `sessionRef`; strip `sessionId` and `resumeSessionId` when `sessionRef` exists. Keep runtime-only handles only for a same-server local payload with matching `serverInstanceId` and no `sessionRef`. Never write a runtime-only handle into a remote/cross-server publication boundary. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts` must assert both durable and same-server runtime-only cases. + +5. Storage-key migration and already-stamped repair. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts`. + - Inputs: `freshell.tabs.v2`, `freshell.panes.v2`, `freshell.layout.v3`, and clients already stamped by the broken branch. + - Enforcement: recoverable v2 tabs/panes are migrated into a valid v3 layout before old keys are removed. Valid v3 layout wins over stale v2 data. Corrupt v3 layout plus valid v2 data is salvaged. The storage version bump reruns repair for already-stamped clients. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts`. + +6. Same-browser cross-tab synchronization. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`. + - Inputs: storage events, cross-tab writeback, current `serverInstanceId`, and pre-ready restored state. + - Enforcement: inbound and outbound payloads run through the canonicalizer with current-server context. Named aliases are never promoted to portable `sessionRef`. Runtime-only panes accepted before WebSocket `ready` reconcile after the current server ID is known to same-server attach, durable `sessionRef` resume, or explicit `restoreError`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts`. + +7. Tab-registry publication, including retained closed records. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts`. + - Inputs: open local tabs, pane snapshots, and retained `localClosed` records captured before or after canonicalization. + - Enforcement: published rich-agent content is canonical `fresh-agent` and contains only portable identity or explicit `restoreError`. Do not publish legacy `agent-chat`, `sessionId`, `resumeSessionId`, or `serverInstanceId`. Preserve provider-specific settings that are portable: Claude `modelSelection` and Codex runtime model/sandbox settings. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts`. + +8. Remote tab rehydration and copy UI. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx`. + - Inputs: remote tab-registry records, remote closed records, context-menu whole-tab copy, and `openPaneInNewTab()` for a selected remote pane. + - Enforcement: remote rich-agent snapshots rehydrate as canonical `fresh-agent`; remote runtime handles are dropped; remote named aliases become visible `restoreError`; Freshcodex `sessionRef` and Codex settings are preserved; copied tab mode is derived from sanitized content across the copied tree, and a copied rich-agent pane opens in `mode: 'shell'`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx`. + +9. Local tab opening, fallback identity, no-layout restore, and reopen stack. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts`. + - Inputs: `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, `reopenClosedTab`, tab-level identity, no-layout tab restore, and history/sidebar/context-menu resume requests. + - Enforcement: tab-level fallback identity reads canonical `fresh-agent.sessionRef`. Closed-tab snapshots are sanitized before storage and reopening. No-layout restore creates canonical fresh-agent content from durable tab identity and shows `restoreError` for nonportable identity instead of recreating legacy `agent-chat`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts`. + +10. Client session locators and UI projections. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts`. + - Inputs: canonicalized pane trees used for focus/dedupe, sidebar fallback rows, and busy/active grouping. + - Enforcement: project durable keys from `sessionRef` first. Use runtime handles only for current-server live grouping when no durable identity exists. Named aliases may keep same-server visibility but cannot become durable or cross-device keys. SessionRef-only panes remain visible after persistence strips runtime handles. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts`. + +11. Server-orchestrated tab creation and command replay. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts`. + - Inputs: UI command replay, `/api/tabs` requests, and MCP tool requests with raw mode/provider/resume values. + - Enforcement: these paths call the same canonical content builder as the UI, not custom `sessionRef` construction. Provider/session-type mapping is explicit. Invalid or nonportable identities are rejected clearly or converted to explicit restore errors before Redux ingestion. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts`. + +12. Server layout sync, layout schema, and layout store. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/layoutMirrorMiddleware.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts`. + - Inputs: live `ui.layout.sync`, server layout API writes, and server layout reads. + - Enforcement: live layout sync advertises canonical `sessionRef` first and may include same-server runtime handles only for live runtime-only Claude panes. Server persisted layout ingress is not opaque for rich-agent content; it validates/canonicalizes `fresh-agent`, rejects `sessionRef` plus `restoreError`, and does not store legacy or stale runtime identity. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/server/ws-tabs-registry.test.ts` if needed for integration fidelity, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts`. + +13. Fresh-agent WebSocket create transport and reconnect replay. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/fresh-agent-ws.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`. + - Inputs: `freshAgent.create`, reconnect reliable-message replay, duplicate `requestId` idempotency, and `freshAgent.created` responses. + - Enforcement: create payloads accept and preserve `sessionRef`, Claude `modelSelection`, opaque effort strings, Codex runtime settings, and request IDs. Reconnect replay sends the original complete create message, not a reconstructed minimal shape. Cached `freshAgent.created` replay preserves the original durable `sessionRef`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`. + +14. Runtime manager and provider adapters. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts`. + - Inputs: provider create requests containing `sessionRef`, `resumeSessionId`, provider settings, and runtime options. + - Enforcement: validate all supplied locators before precedence. Provider mismatches and conflicting durable IDs fail clearly. Codex `resumeSessionId` is durable and conflicts with a different Codex `sessionRef`; Claude noncanonical aliases may coexist only as same-server live attach handles. Codex create/resume returns durable `sessionRef`; Claude returns `sessionRef` only from trusted canonical history/timeline metadata. Claude adapter resolves transported `modelSelection` into SDK model input. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts`. + +15. Fresh-agent client lifecycle and recovery. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts`. + - Inputs: create requests, `freshAgent.created`, create failures, snapshot refresh, retry, lost-session recovery, split/reload restore, and sessionRef-only restored panes. + - Enforcement: async updates use fresh refs or targeted merges and do not clobber newer pane fields. `restoreError` suppresses automatic new-session create until valid durable identity appears. Recovery prefers canonical `sessionRef`; valid durable identity clears stale `restoreError` and flushes persistence. SessionRef-only restores use create/resume and wait for durable history hydration. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts`. + +16. Provider-aware new pane creation. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx`. + - Inputs: user-created FreshClaude/FreshAgent/Freshcodex panes. + - Enforcement: Claude-backed panes start with `modelSelection` and opaque Claude effort fields, not runtime `model`. Freshcodex panes keep Codex `model`, `sandbox`, and Codex settings and do not gain Claude `modelSelection`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx`. + +17. Settings normalization and clear sentinels. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts`. + - Inputs: outgoing client patches, `/api/settings` route patches, config-store load/merge, server broadcasts, reducer hydration, and optimistic preview state. + - Enforcement: no own `undefined` properties at any depth. Clear operations use explicit `null` sentinels where supported. `agentChat` and `freshAgent` aliases normalize to the same provider settings contract; legacy `defaultModel` / `defaultEffort` become `modelSelection` / `effort` without leaking stale fields. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts`. + +18. Pane-tree validation. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts`. + - Inputs: persisted, cross-tab, server-layout, and restored pane trees before they are trusted by the UI. + - Enforcement: validate `fresh-agent.sessionRef`, `restoreError`, `modelSelection`, opaque non-empty Claude effort, and Codex sandbox/provider fields. Reject malformed variants and reject `sessionRef` plus `restoreError`. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts`. + +19. Legacy harness fidelity and selector stability. + - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx`. + - Inputs: legacy `AgentChatView` component harnesses after reducer canonicalization and context-menu selectors with empty fallback values. + - Enforcement: legacy component tests may mount `AgentChatView` directly, but the wrapper must keep the component mounted after production canonicalization and still pass updated settings/retry props. Context-menu selectors use stable module-level empty objects/arrays. + - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx`. + +20. Final verification and integration base. + - Owners: this plan and the implementation branch `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation`. + - Inputs: all task commits after the plan, branch sync against `origin/dev`, browser smoke coverage, build, lint, typecheck, and coordinated full suite. + - Enforcement: compare and integrate against `origin/dev`, not `origin/main`. Final verification includes focused unit/server tests, browser e2e for fresh-agent desktop and mobile, `npm run build`, `npm run lint`, `npm run typecheck`, `FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check`, and `git diff --check`. + - Proof: final handoff records exact command output and any remaining unrelated failures with paths and evidence. + +## File Structure + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx` + - Owns remote tab card copy/rehydration. It should convert legacy `agent-chat` snapshots into canonical `fresh-agent` content, preserve only portable durable identity across servers, preserve native Freshcodex fields, and choose copied tab mode from sanitized content. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts` + - Owns publishing local tab/pane records for other devices. It must publish only portable durable identity, must not synthesize `sessionRef` from named aliases or same-server-only handles, and must preserve provider-specific settings such as Claude `modelSelection` and Codex runtime `model`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts` + - Owns programmatic session-opening content such as history/sidebar/context-menu resume flows. It should create `sessionRef` only for canonical durable IDs and leave named aliases as same-server `resumeSessionId`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts` + - Owns client-side open-session locator derivation used by app/sidebar focus and dedupe flows. It must advertise canonical `fresh-agent.sessionRef` before any runtime handle and must not expose `fresh-agent.resumeSessionId` as portable identity when a durable `sessionRef` exists. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts` + - Owns portable session-reference, restore-error, and legacy durable-state migration contracts. It should distinguish context-sensitive same-server aliases from cross-server restore failures, expose a single Claude durable-ID predicate, and keep `RestoreError` as `{ code, reason }`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts` + - Owns client-side Claude durable-ID checks. It must delegate to or exactly match the shared durable-ID predicate so recovery, reducer migration, and tests do not disagree about valid Claude identities. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts` + - Owns reducer-boundary pane normalization. It should normalize legacy `agent-chat` to `fresh-agent`, derive canonical `sessionRef` only from valid durable Claude IDs, and strip provider-inappropriate model fields. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts` + - Owns same-browser cross-tab hydration and writeback. It must run canonical pane normalization and same-server runtime-handle reconciliation instead of synthesizing portable `sessionRef` from arbitrary `resumeSessionId` values. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx` + - Owns no-layout tab restore where pane content is synthesized from tab-level identity. It must build canonical `fresh-agent` content from durable `sessionRef` and must not recreate legacy `agent-chat` or runtime-only identities. + +- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts` + - Shared client helper for pane-content canonicalization used by reducer, persistence, cross-tab, tab-registry, no-layout restore, UI command, and activity projection boundaries. This helper should encode context-specific policy instead of spreading ad hoc `kind === 'agent-chat'` checks across production code. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts` + - Owns persisted and hydrated pane shape validation. It must validate `fresh-agent.sessionRef`, `fresh-agent.restoreError`, `fresh-agent.modelSelection`, and opaque non-empty Claude effort strings without accepting malformed provider-specific fields. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts` + - Owns tab-level session identity when opening/copying tabs. It must set tab fallback identity from canonical `fresh-agent.sessionRef`, not only from legacy `agent-chat` content. + - Also owns `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, and `reopenClosedTab`; those paths must preserve tab-level `sessionRef`, canonicalize saved layouts before storing/reopening, and never reintroduce remote runtime handles. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts` + - Owns derived fallback tab/session identity. It must understand `fresh-agent.sessionRef` after persisted payloads strip runtime handles. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts` + - Owns sidebar fallback session rows. It must use `fresh-agent.sessionRef` when `resumeSessionId` has correctly been stripped from persisted or cross-tab payloads. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts` + - Owns persisted pane migration/writeback. It should strip stale Claude `model`, preserve Codex runtime `model`, and preserve canonical `sessionRef` while removing same-server-only runtime fields from persisted/cross-tab payloads. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts` + - Owns persisted layout parsing used by localStorage load, storage migration, and cross-tab sync. It must run the same provider-specific pane canonicalization as reducers and persist middleware. + +- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/layoutMirrorMiddleware.ts` + - Owns live `ui.layout.sync` payload dispatch. It should continue sending live pane state while server-side locator extraction handles canonical `fresh-agent` identity and same-server runtime handles correctly. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts` + - Owns reliable message tracking and reconnect replay. It must replay expanded `freshAgent.create` payloads byte-for-contract-byte, not just `requestId`, so reconnect cannot drop `sessionRef` or provider settings. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts` + - Owns replay of server-orchestrated UI commands such as `/api/tabs` open requests. It must convert command payloads into canonical fresh-agent pane content and reject/mark nonportable identities before Redux ingestion. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts` + - Owns busy/active session grouping. It must derive keys from canonical `sessionRef` for sanitized fresh-agent panes, keep same-server runtime handles local, and prevent named aliases from becoming durable busy keys. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts` + - Owns publishing open tabs and retained `localClosed` tab records. It must canonicalize retained closed records before publication so stale closed-tab snapshots cannot leak legacy `agent-chat` content or runtime handles to other devices. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts` + - Owns one-time localStorage version repair. It should migrate or salvage v2 tab/pane keys into `freshell.layout.v3`, remove v2 keys after safe migration, and rerun for already-stamped broken branch clients. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts` + - Owns fresh-agent WS message schemas. `freshAgent.create` should accept `sessionRef`, `modelSelection`, and opaque non-empty effort strings. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts` + - Owns runtime adapter request types. It should match the provider-specific create payload accepted by WS, including optional `sessionRef` and Claude `modelSelection`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts` + - Owns fresh-agent create/resume routing. It should validate every supplied locator for provider/id consistency before choosing live attach precedence, prefer same-provider `sessionRef.sessionId` when no same-server runtime handle exists, reject mismatched locators clearly, and pass provider-specific settings through to adapters. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts` + - Owns server-side Claude durable-ID checks used by restore, terminal tracking, and timeline decisions. It must share the same durable-ID grammar as `shared/session-contract.ts` and `src/lib/claude-session-id.ts`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts` + - Owns translating Claude fresh-agent create input into SDK bridge input. It must resolve `modelSelection` into the actual SDK `model` value and must preserve opaque Claude effort values. + +- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts` + - Owns Codex create response identity. It should return a durable Codex `sessionRef` for newly created/resumed thread ids so the client does not infer provider durability from raw runtime handles. + +- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts` + - Owns WS create validation, created replay/idempotency, `ui.layout.sync` session-locator extraction, and error responses. It should surface clear create failures for mismatched locators or invalid provider-specific create settings, preserve `sessionRef` in cached `freshAgent.created` replay, and keep live same-server FreshAgent panes visible to server-side open-session tracking. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts` + - Owns server-side layout payload validation. It must stop accepting rich-agent pane content as an opaque record where that would persist noncanonical identity, while preserving forward-compatible validation for unrelated pane content. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts` + - Owns server-side storage and retrieval of UI layout snapshots. It must canonicalize rich-agent pane content on write/read so `ui.layout.sync` and agent API callers cannot persist legacy identity or stale runtime handles. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts` + - Owns `/api/tabs` and other server-orchestrated tab creation ingress. It must accept only canonical portable rich-agent identity for cross-process opening and route invalid named aliases to explicit restore errors rather than durable `sessionRef`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts` + - Owns MCP-driven tab creation. It must use the same session-type/runtime-provider mapping and durable-ID checks as the UI/API path instead of constructing `sessionRef` directly from raw `mode` / `resume` values. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts` + - Owns server settings sanitization/merge. It should normalize legacy `defaultModel` / `defaultEffort` into canonical `modelSelection` / `effort` for both `freshAgent` and `agentChat` aliases. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts` only if real `ConfigStore.load()` compatibility tests expose a gap that cannot be fixed in `shared/settings.ts`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts` + - Owns client API patch normalization. It should normalize provider clear sentinels for all present aliases and prune/convert own `undefined` fields before calling `/api/settings`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts` + - Owns hydrated and optimistic client settings state. It must ingest server settings through the same canonical alias/modelSelection contract so UI state cannot remain stale after API/server fixes. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts` + - Owns `/api/settings` patch normalization. It must accept and clear `freshAgent.providers.*` sentinels through the same route-level behavior already covered for legacy `agentChat`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx` + - Owns user-visible new-pane creation. It must create Claude-backed `fresh-agent` panes with `modelSelection` rather than runtime `model`, while keeping Freshcodex runtime `model` fields provider-specific. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts` + - Owns reusable durable identity helpers. Add a fresh-agent identity update helper here if `FreshAgentView` needs the same persisted identity/flush behavior already used by legacy `AgentChatView`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx` + - Owns fresh-agent client lifecycle. It should send provider-specific create settings, recover with the freshest canonical durable ID, persist/flush canonical `sessionRef`, and use stale-update-safe targeted merges. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts` + - Owns pending-create and session lifecycle reducer state. It must copy `expectsHistoryHydration` into actual session state for sessionRef-only restores so restored transcripts wait for durable history. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx` + - Owns context menu selectors. It should use stable module-level empty fallback objects/arrays. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx` + - Assert remote legacy `agent-chat` rehydrates as canonical `fresh-agent`, copied tab mode is `shell`, named aliases are not portable, and remote same-server-only handles are dropped. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx` + - Assert native Freshcodex remote snapshots preserve `sessionRef` and Codex runtime fields while dropping remote `resumeSessionId` / `sessionId`, and reject `resumeSessionId`-only remote snapshots as non-portable. + +- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts` + - Assert well-formed `fresh-agent` panes accept opaque Claude effort strings plus either valid `sessionRef` or valid `restoreError`, and reject malformed variants or `sessionRef` plus `restoreError` together. + +- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts` + - Assert fallback tab identity is derived from `fresh-agent.sessionRef` after same-server runtime handles are removed. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts` + - Assert open-session locators prefer `fresh-agent.sessionRef`, omit stale `resumeSessionId` when durable identity exists, and keep same-server runtime-only Claude handles only for live local flows. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/session-contract.test.ts` + - Assert the shared Claude durable-ID predicate, context-aware durable-state migration, and `sessionRef` / `restoreError` mutual exclusion. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/claude-session-id.test.ts` + - Assert the client Claude durable-ID predicate exactly matches or delegates to the shared predicate. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts` + - Add or extend server-side durable-ID predicate coverage through an existing server caller so restore/tracking decisions cannot diverge from the shared/client predicate. + +- Modify or extend `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts` + - Assert sidebar fallback session rows include sessionRef-only `fresh-agent` panes and do not depend on stripped `resumeSessionId`. + +- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts` + - Assert local tab-registry publication preserves only portable identities and provider-specific settings for legacy agent-chat and fresh-agent panes. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts` + - Assert `buildResumeContent()` does not create invalid portable Claude identities from named aliases and still preserves canonical durable IDs. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts` + - Replace obsolete raw `agent-chat` expectations with reducer-boundary canonicalization coverage, including valid canonical IDs and named-alias non-portability. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts` + - Assert cross-tab hydration canonicalizes legacy rich-agent panes, preserves only same-server runtime handles when `serverInstanceId` matches, strips handles after `sessionRef` exists, and never promotes named aliases to portable identity. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx` + - Assert no-layout tab restore creates canonical fresh-agent content from tab-level `sessionRef` and renders restore errors for nonportable identity instead of legacy agent-chat fallback content. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts` + - Assert `openSessionTab`, closed-tab recording, and `reopenClosedTab` preserve canonical `fresh-agent.sessionRef`, tab-level identity, and sanitized layouts. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` + - Assert Claude-backed migrated panes drop stale `model`, Codex panes keep runtime `model` without gaining `modelSelection`, and persisted parser/cross-tab paths cannot bypass the canonicalization. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts` + - Assert retained `localClosed` records are canonicalized before publication and cannot broadcast legacy rich-agent content or stale runtime handles. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts` + - Assert v2 keys migrate into v3 layout before removal, already-stamped broken clients are repaired, and corrupt layout plus valid v2 data is salvaged. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts` + - Assert clear sentinels normalize correctly and no own `undefined` properties are sent. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts` + - Assert hydrated and optimistic client settings state canonicalizes `agentChat` / `freshAgent` aliases, clear sentinels, and `modelSelection` fields exactly like the server contract. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts` + - Assert shared settings sanitization/merge canonicalizes both aliases. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts` + - Assert focused server merge compatibility uses canonical `modelSelection` / `effort`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts` + - Assert real persisted legacy config loaded by `ConfigStore.load()` yields canonical mirrored `freshAgent` and `agentChat` settings. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts` + - Assert `/api/settings` accepts and clears `freshAgent.providers.*` model/effort sentinels without undefined properties and mirrors compatibility aliases correctly. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts` + - Assert server-side layout schema validates canonical fresh-agent content and rejects or repairs noncanonical rich-agent identity instead of accepting arbitrary records. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts` + - Assert server-side layout store canonicalizes rich-agent panes on write/read and does not persist runtime handles or legacy pane identity from `ui.layout.sync`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts` + - Assert MCP tab creation uses canonical rich-agent identity and does not promote raw resume aliases into durable session refs. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts` + - Assert server-originated tab-open commands replay into canonical fresh-agent pane content before Redux ingestion. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx` + - Assert new FreshClaude/FreshAgent panes use Claude `modelSelection` / opaque effort fields while Freshcodex panes keep runtime `model` / Codex settings. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx` + - Assert canonical durable recovery beats named aliases, named fallback still works when no canonical durable ID exists, and canonical identity is persisted. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx` + - Align persisted FreshAgent reload expectations with the sessionRef-driven create/resume contract instead of the old direct attach-from-runtime-handle contract. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx` + - Align refresh/split-pane restore expectations with the sessionRef-driven create/resume contract and same-server handle boundary. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` + - Add stale-update regression coverage for created/create-failed/snapshot/retry/recovery paths. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts` + - Assert fresh-agent create cancellation and late-create behavior still work with the expanded create payload. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts` + - Assert WebSocket reconnect replay preserves the full expanded fresh-agent create payload, including `sessionRef` and provider settings. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts` + - Assert sessionRef-only fresh-agent panes still contribute busy/activity keys, same-server runtime handles remain local, and named aliases do not become durable grouping keys. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts` + - Assert pending create state with `expectsHistoryHydration: true` produces reducer session state with `historyLoaded: false` and `awaitingDurableHistory: true`. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts` + - Assert create/resume locator precedence, mismatch errors, and provider-specific create payload handling. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts` + - Assert Claude adapter resolves transported `modelSelection` into the SDK bridge `model` field and preserves opaque effort values. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts` + - Assert WS create accepts valid Claude dynamic effort/modelSelection, rejects mismatched locators clearly, preserves Freshcodex create settings, preserves `sessionRef` in idempotent `freshAgent.created` replay, and extracts live fresh-agent locators from `ui.layout.sync`. + +- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/server/ws-tabs-registry.test.ts` + - Add an integration-level guard for live `ui.layout.sync` open-session tracking if `ws-handler-fresh-agent.test.ts` cannot cover that boundary faithfully. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` + - Repair the legacy component harness so it keeps `AgentChatView` mounted after reducer canonicalization while still feeding it updated settings/retry state. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx` + - Add or extend a warning regression test for stable selector fallbacks. + +- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e-browser/specs/fresh-agent-mobile.spec.ts` + - Include the existing mobile restored FreshAgent browser smoke in final verification because this plan changes restored pane identity and lifecycle behavior. + +## Known Red Checks + +These known failures are in scope and must be green before the work is complete. Do not run paths marked `Modify or create` until the task step has created those files; the red phase should fail on product behavior, not on "file not found". + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/tab-fallback-identity.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/store/paneTreeValidation.test.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/storage-migration.test.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +``` + +```bash +npm run test:server -- --run \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/integration/server/settings-api.test.ts +``` + +Final verification must also include: + +```bash +npm run typecheck +npm run lint +npm run build +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts +FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check +git diff --check +``` + +If coordinated `npm run check` reports additional failures touching fresh-agent, freshcodex, legacy agent-chat compatibility, settings, pane persistence, storage migration, remote tab rehydration, or the context menu warning fixed here, continue fixing them in this same implementation cycle. If it reports genuinely unrelated pre-existing failures, stop with exact paths, logs, and evidence; do not silently declare success. + +### Task 1: Lock Canonical Pane Identity And Remote Rehydration + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts` +- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx` +- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts` +- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts` + +- [ ] **Step 1: Identify the failing tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/panesSlice.test.ts +npm run test:server -- --run \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts +``` + +Expected before changes: remote legacy `agent-chat` snapshots either remain `agent-chat`, keep stale CLI tab mode, synthesize non-portable resume aliases, publish invalid portable identities, fail to preserve native Freshcodex durable identity correctly, lose no-layout/tab-level identity, or let server-orchestrated open paths promote raw aliases. + +- [ ] **Step 2: Update tests to assert the steady-state identity contract** + +In `test/unit/client/components/TabsView.test.tsx`, update the remote legacy `agent-chat` copy test so the copied pane content is canonical: + +```ts +expect(copiedTab.mode).toBe('shell') +expect(copiedLayout.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionRef: { + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000444', + }, + modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' }, + permissionMode: 'plan', + effort: 'turbo', + plugins: ['planner'], +}) +expect(copiedLayout.content.serverInstanceId).toBeUndefined() +expect(copiedLayout.content.resumeSessionId).toBeUndefined() +expect(copiedLayout.content.sessionId).toBeUndefined() +``` + +Add a sibling legacy remote test where `resumeSessionId: 'named-resume'` is the only legacy identity. Assert it becomes `fresh-agent` but has no `sessionRef`, no remote `resumeSessionId`, and a visible `restoreError` if the existing contract supports one. If the current product intentionally opens an un-restorable shell with no error for remote named aliases, change the implementation to provide the clear `restoreError`; do not silently create a new durable session. + +In `test/unit/client/components/TabsView.fresh-agent.test.tsx`, strengthen native Freshcodex remote coverage: + +```ts +expect(copiedTab.mode).toBe('shell') +expect(copiedLayout.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + sessionRef: { + provider: 'codex', + sessionId: 'codex-thread-123', + }, + model: 'codex-model', + sandbox: 'workspace-write', +}) +expect(copiedLayout.content.resumeSessionId).toBeUndefined() +expect(copiedLayout.content.sessionId).toBeUndefined() +expect(copiedLayout.content.modelSelection).toBeUndefined() +``` + +Add a negative native `fresh-agent` remote snapshot test where the payload has only `resumeSessionId: 'codex-runtime-handle'` and no `sessionRef`. Assert the copied pane has no `sessionRef`, no remote `resumeSessionId`, and a visible `restoreError`. This covers the canonical `fresh-agent` steady-state case, not only legacy `agent-chat` snapshots. + +Add a multi-pane context-menu test for `openPaneInNewTab()`: make the remote record's first pane a terminal or legacy agent pane, open a later sanitized rich-agent pane in its own tab, and assert the new tab uses `mode: 'shell'` derived from the clicked pane's sanitized content rather than `deriveModeFromRecord(record)`. + +Add a whole-tab multi-pane copy test where the first raw pane is terminal/CLI-like and a later pane sanitizes to `fresh-agent`. Assert the copied tab uses `mode: 'shell'` because the sanitized tree contains a rich-agent pane anywhere in the copied layout. + +In `test/unit/client/lib/tab-registry-snapshot.test.ts`, add publication tests for `buildOpenTabRegistryRecord()` / `collectPaneSnapshots()`: + +- legacy `agent-chat` with a canonical Claude durable ID publishes canonical `kind: 'fresh-agent'`, `sessionRef`, and `modelSelection`, and does not publish `sessionId`, `resumeSessionId`, or `serverInstanceId`; +- legacy `agent-chat` with `resumeSessionId: 'named-resume'` does not publish `sessionRef`; +- native `fresh-agent` Codex publishes canonical `kind: 'fresh-agent'`, explicit `sessionRef`, Codex `model`, and Codex runtime fields, and does not publish same-server runtime handles; +- native `fresh-agent` with only `resumeSessionId` does not synthesize a portable `sessionRef`. + +In `test/unit/client/lib/session-type-utils.test.ts`, add `buildResumeContent()` coverage: + +- `freshclaude` / `kilroy` with a canonical Claude durable ID include `sessionRef`; +- `freshclaude` / `kilroy` with a named alias keep `resumeSessionId` but omit `sessionRef`; +- `freshcodex` with a Codex durable thread ID includes `sessionRef`; +- provider-specific defaults remain provider-specific (`modelSelection` for Claude-backed sessions, runtime `model` for Codex). + +In `test/unit/client/lib/session-utils.test.ts`, add open-session locator coverage: + +- a `fresh-agent` pane with `sessionRef` and `resumeSessionId` yields a single durable locator based on `sessionRef`, not a duplicate/stale `resumeSessionId` locator; +- a `fresh-agent` pane with only a same-server Claude runtime `resumeSessionId` yields a live local locator but is not marked portable; +- a Freshcodex pane with conflicting `sessionRef` and `resumeSessionId` is treated as invalid/ambiguous by any helper that would otherwise advertise it. + +In `test/unit/client/lib/pane-activity.test.ts`, add activity-key projection coverage for canonical `fresh-agent` panes: sessionRef-only panes remain grouped as busy/active after runtime handles are stripped, current-server runtime-only handles are local-only, and named aliases do not pollute durable activity keys. + +In `test/unit/shared/session-contract.test.ts`, `test/unit/client/lib/claude-session-id.test.ts`, and server-side coverage through `test/unit/server/terminal-registry.test.ts`, add direct predicate coverage so the shared, client, and server Claude durable-ID checks accept and reject the same fixture IDs. + +In `test/unit/client/lib/tab-fallback-identity.test.ts`, add coverage that a single-pane tab containing `fresh-agent.sessionRef` yields a stable fallback identity even when `sessionId` and `resumeSessionId` are absent. + +In `test/unit/client/store/selectors/sidebarSelectors.test.ts`, add coverage that sessionRef-only `fresh-agent` panes still appear in sidebar fallback session rows after persisted/cross-tab sanitization has stripped `resumeSessionId`. + +In `test/unit/client/components/TabContent.test.tsx`, add no-layout restore coverage: a tab with only tab-level `sessionRef` synthesizes canonical `fresh-agent` content, while a tab with only a nonportable named alias renders a restore error and does not synthesize legacy `agent-chat` content. + +In `test/unit/client/store/crossTabSync.test.ts`, add cross-tab coverage for canonicalization on incoming payloads and writeback: runtime handles are retained only when tagged with the current `serverInstanceId` and no durable `sessionRef` exists; once `sessionRef` exists, `sessionId` and `resumeSessionId` are stripped; named aliases are never promoted to portable `sessionRef`. + +In `test/unit/client/store/tabsSlice.test.ts`, add `openSessionTab` and reopen-stack coverage: opening a fresh-agent session tab copies tab-level `sessionRef` for both legacy and canonical input, closed-tab snapshots are sanitized before storage, and `reopenClosedTab` restores canonical fresh-agent content instead of raw saved legacy/runtime-only content. + +In `test/unit/client/ui-commands.test.ts`, add a server-originated tab-open command that carries rich-agent identity through `/api/tabs` replay. Assert the command is canonicalized before reducer ingestion and that nonportable aliases become explicit restore errors. + +In `test/unit/server/mcp/freshell-tool.test.ts`, add MCP tab creation coverage for the same identity contract: raw `resume` aliases must not become portable `sessionRef`, and runtime provider/session type mapping must be explicit. + +In `test/unit/client/store/panesSlice.test.ts`, replace the obsolete assertion that no `sessionRef` is synthesized with two stronger cases: + +```ts +it('normalizes legacy agent-chat freshclaude input with a canonical Claude id to fresh-agent', () => { + const state = panesReducer( + initialState, + initLayout({ + tabId: 'tab-1', + content: { + kind: 'agent-chat', + provider: 'freshclaude', + resumeSessionId: VALID_CLAUDE_SESSION_ID, + }, + }), + ) + + const leaf = state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: VALID_CLAUDE_SESSION_ID, + sessionRef: { + provider: 'claude', + sessionId: VALID_CLAUDE_SESSION_ID, + }, + }) +}) + +it('does not synthesize a portable sessionRef from a named legacy resume alias', () => { + const state = panesReducer( + initialState, + initLayout({ + tabId: 'tab-1', + content: { + kind: 'agent-chat', + provider: 'freshclaude', + resumeSessionId: 'named-resume', + }, + }), + ) + + const leaf = state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: 'named-resume', + }) + expect(leaf.content.sessionRef).toBeUndefined() +}) +``` + +- [ ] **Step 3: Run tests to verify they fail for the intended gaps** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/tab-fallback-identity.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/panesSlice.test.ts +npm run test:server -- --run \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts +``` + +Expected: failures point to missing remote conversion, stale tab mode, invalid publisher/session-helper identity synthesis, no-layout restore gaps, server-orchestrated open path gaps, cross-tab alias promotion, open/reopen stack identity loss, missing native Freshcodex assertions, or reducer sessionRef derivation. + +- [ ] **Step 4: Implement canonicalization at reducer and remote snapshot boundaries** + +In `shared/session-contract.ts`, first make the durable-state helper context-aware. The helper should separate "same-server local normalization" from "remote/cross-server portable restore": + +- In local reducer/session-opening contexts, a named Claude `resumeSessionId` remains a same-server runtime alias and does not become `restoreError`. +- In remote/cross-server contexts, a named Claude alias is not portable and becomes `restoreError: buildRestoreError('missing_canonical_identity')`. +- Trusted `sessionRef`, `cliSessionId`, and `timelineSessionId` values are accepted through the shared Claude durable-ID predicate; a bare `resumeSessionId` is promoted only if it satisfies that predicate. + +Update `src/lib/claude-session-id.ts` to delegate to or exactly match the shared predicate so client recovery tests and shared migration agree. + +In `src/store/panesSlice.ts`, canonicalize `agent-chat` through that context-aware durable-state helper rather than ad hoc string checks: + +- For legacy `agent-chat` with a valid `sessionRef`, valid `cliSessionId`, valid `timelineSessionId`, or valid canonical `resumeSessionId`, produce `sessionRef`. +- For legacy `agent-chat` with a named `resumeSessionId`, preserve it only as same-pane `resumeSessionId`; do not create `sessionRef`. +- For Claude-backed `fresh-agent`, ensure stale `model` is removed after conversion to `modelSelection`. +- For Codex-backed `fresh-agent`, preserve `model` and do not synthesize `modelSelection`. + +Create or update `src/lib/pane-content.ts` so reducer, persistence, cross-tab, tab-registry, no-layout restore, UI command, and activity projection boundaries share the same context-aware rich-agent canonicalization. Keep helpers small and type-focused, such as: + +```ts +export function normalizeFreshAgentPaneModelFields(input: { + provider?: unknown + model?: unknown + modelSelection?: unknown + effort?: unknown +}): { + model?: string + modelSelection?: AgentChatModelSelection + effort?: string +} { + if (input.provider === 'codex') { + return { + model: typeof input.model === 'string' ? input.model : undefined, + effort: normalizeAgentChatEffortOverride(input.effort), + } + } + return { + modelSelection: normalizeAgentChatModelSelection(input.modelSelection, input.model), + effort: normalizeAgentChatEffortOverride(input.effort), + } +} +``` + +In `src/components/TabsView.tsx`, change `sanitizePaneSnapshot()` so `snapshot.kind === 'agent-chat'` returns canonical `fresh-agent` content. Use durable-state migration, not raw `resumeSessionId`, to build portable `sessionRef`. Preserve `resumeSessionId` and `sessionId` only when `sameServer` is true. For remote named aliases, return a clear `restoreError` rather than silently starting a new session. + +For native `snapshot.kind === 'fresh-agent'`, preserve explicit `sessionRef`, preserve Freshcodex runtime fields, and drop remote `resumeSessionId` / `sessionId` unless `sameServer` is true. Do not use `resumeSessionId` as a `sessionRef` fallback for remote native fresh-agent snapshots; if no portable identity remains, set `restoreError`. + +Update copied tab mode derivation so mode is based on sanitized content. A copied tab with any sanitized `fresh-agent` pane anywhere in the copied tree must be `mode: 'shell'`, even if the remote registry snapshot was legacy `agent-chat` or the first raw pane was terminal-like. Apply the same rule in `openPaneInNewTab()`: derive mode from the sanitized clicked pane, not from the whole remote record's first pane. + +In `src/lib/tab-registry-snapshot.ts`, use the same durable-state helper to publish portable identity. Never synthesize `sessionRef` from a named alias or a same-server-only handle. Preserve `modelSelection` for Claude-backed panes and Codex runtime `model` / `sandbox` / settings for Codex panes. + +Publish canonical `fresh-agent` snapshot content for rich-agent panes. Do not publish legacy `agent-chat` `kind`, `sessionId`, `resumeSessionId`, or `serverInstanceId` across devices. Same-server-only handles are local runtime implementation details, not tab-registry data. + +In `src/lib/session-type-utils.ts`, update `buildResumeContent()` so explicit `sessionRef` is created only when `opts.sessionId` is a canonical durable ID for the runtime provider. Non-canonical Claude aliases remain `resumeSessionId` only. + +In `src/store/tabsSlice.ts`, `src/components/TabContent.tsx`, and `src/lib/tab-fallback-identity.ts`, teach tab-level identity derivation about canonical `fresh-agent.sessionRef`. This prevents a correctly sanitized rich-agent pane from losing tab identity just because it no longer has legacy `agent-chat` content or runtime handles. Apply the same canonicalization when `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, and `reopenClosedTab` create, store, or restore a rich-agent layout. + +In `src/store/selectors/sidebarSelectors.ts`, read `fresh-agent.sessionRef` before same-server `resumeSessionId` for fallback session rows. SessionRef-only panes must remain visible after persistence strips runtime handles. + +In `src/store/crossTabSync.ts`, canonicalize incoming and outgoing pane payloads with the same helper and current-server context. Do not synthesize a portable `sessionRef` from a named `resumeSessionId`; do not reapply runtime `sessionId` / `resumeSessionId` after a durable `sessionRef` is known; and preserve current-server runtime-only handles only until WebSocket `ready` can reconcile the server instance. + +In `src/lib/ui-commands.ts`, `server/agent-api/router.ts`, and `server/mcp/freshell-tool.ts`, route all server-orchestrated rich-agent tab creation through the same canonical content builder. These paths must not construct `sessionRef` directly from raw `mode`, `resume`, or request payload strings. + +In `src/lib/pane-activity.ts`, derive fresh-agent activity keys from canonical `sessionRef` first and runtime handles only for current-server live grouping. Named aliases can keep same-server visibility but must not become durable activity keys that cross devices or persist after sanitization. + +In `server/claude-session-id.ts`, remove any duplicate grammar or make it delegate to the shared predicate. Server restore, terminal tracking, and timeline decisions must agree with client migration/recovery tests. + +- [ ] **Step 5: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/tab-fallback-identity.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/panesSlice.test.ts +npm run test:server -- --run \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts +``` + +Expected: all selected tests pass for the canonical identity contract. + +- [ ] **Step 6: Commit** + +```bash +git add \ + src/components/TabsView.tsx \ + src/components/TabContent.tsx \ + src/lib/tab-registry-snapshot.ts \ + src/lib/session-type-utils.ts \ + src/lib/session-utils.ts \ + shared/session-contract.ts \ + src/lib/claude-session-id.ts \ + server/claude-session-id.ts \ + src/store/panesSlice.ts \ + src/store/crossTabSync.ts \ + src/store/tabsSlice.ts \ + src/lib/tab-fallback-identity.ts \ + src/store/selectors/sidebarSelectors.ts \ + src/lib/ui-commands.ts \ + src/lib/pane-activity.ts \ + server/agent-api/router.ts \ + server/mcp/freshell-tool.ts \ + src/lib/pane-content.ts \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/tab-fallback-identity.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts +git commit -m "Canonicalize fresh-agent pane identity" +``` + +If `src/lib/pane-content.ts` was not created, omit it from `git add`. + +### Task 2: Make Fresh-Agent Create Payloads Provider-Aware + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts` +- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts` +- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts` + +- [ ] **Step 1: Identify or write failing protocol tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx +npm run test:server -- --run \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts +``` + +Expected before changes: fresh-agent create does not support enough provider-specific fields, adapter settings resolution, or locator semantics to resume remote copied Freshcodex/FreshClaude panes safely. + +- [ ] **Step 2: Add tests for provider-aware create payloads** + +In `test/unit/server/ws-handler-fresh-agent.test.ts`, add coverage that `freshAgent.create` accepts: + +```ts +{ + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshclaude', + provider: 'claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' }, + effort: 'turbo', +} +``` + +Assert no Zod/schema validation failure occurs for opaque effort strings. + +Add a mismatch case: + +```ts +{ + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshcodex', + provider: 'codex', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, +} +``` + +Expected result: a clear `freshAgent.create.failed` with a locator-mismatch code such as `FRESH_AGENT_SESSION_LOCATOR_MISMATCH`; do not fall back to creating a new session. + +Add an idempotent replay case: send a `freshAgent.create` request that succeeds with `sessionRef`, resend the same `requestId` after reconnect, and assert the cached `freshAgent.created` replay includes the original `sessionRef` as well as the runtime `sessionId`. + +Add a live layout-sync case: send `ui.layout.sync` containing a `fresh-agent` pane with `sessionRef` and assert server-side open-session tracking records the canonical locator. Add a second case for a current-server runtime-only Claude-backed `fresh-agent` pane with `resumeSessionId` / `sessionId` and matching `serverInstanceId`, and assert the live same-server locator remains tracked. If `ws-handler-fresh-agent.test.ts` cannot exercise this integration faithfully, add the case to `test/server/ws-tabs-registry.test.ts`. + +In `test/unit/server/fresh-agent/runtime-manager.test.ts`, assert create/resume precedence: + +- provider mismatch in any supplied locator fails before resume/create precedence is applied; +- two conflicting canonical durable locators fail clearly instead of silently choosing one; +- Freshcodex `sessionRef: { provider: 'codex', sessionId: 'codex-thread-a' }` plus `resumeSessionId: 'codex-thread-b'` fails clearly because Codex thread IDs are durable and cannot be treated as same-server aliases; +- `resumeSessionId` wins for same-server live resumes only after locator consistency has passed; +- a non-canonical same-server Claude `resumeSessionId` may coexist with matching-provider `sessionRef` for live attach, and persistence still keeps only `sessionRef`; +- `sessionRef.sessionId` is used when `resumeSessionId` is absent and `sessionRef.provider` matches the runtime provider. +- Codex create preserves `model`, `sandbox`, and Codex effort/settings. +- Claude create preserves `modelSelection` and opaque effort strings. + +Add created-identity tests: + +- Codex create/resume returns a `sessionRef` in the created payload because Codex thread ids are durable. +- Claude create does not synthesize `sessionRef` from the SDK bridge runtime `sessionId` alone; it returns/persists `sessionRef` only when the adapter has trusted canonical SDK/timeline history metadata. + +In `test/unit/server/fresh-agent/claude-adapter.test.ts`, assert the Claude adapter resolves `modelSelection` into the actual `sdkBridge.createSession({ model })` value: + +- `{ kind: 'exact', modelId: 'fixture-claude-model' }` becomes `model: 'fixture-claude-model'`; +- tracked/alias model selections are resolved by the same helper used by legacy AgentChat where possible, or by a small shared resolver if the legacy helper is client-only; +- no `modelSelection` means the adapter uses the existing provider default behavior; +- opaque effort strings such as `turbo` are passed through without the shared transport rejecting them. + +In `test/unit/client/lib/fresh-agent-ws.test.ts`, assert create cancellation/late-created handling still works when create messages include `sessionRef` and `modelSelection`. + +In `test/unit/client/lib/ws-client.test.ts`, assert reconnect replay preserves the exact expanded `freshAgent.create` payload for representative FreshClaude and Freshcodex messages: `sessionRef`, `modelSelection`, opaque effort, Codex `model`, Codex `sandbox`, runtime settings, and request id. The replay helper must not rebuild the message from a minimal pending-create shape. + +In `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`, assert user-visible new-pane creation keeps provider fields separate: + +- FreshClaude/FreshAgent panes use `modelSelection` and opaque Claude effort fields and do not get a runtime `model`. +- Freshcodex panes keep runtime `model`, `sandbox`, and Codex settings and do not get Claude `modelSelection`. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx +npm run test:server -- --run \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts +``` + +Expected: validation/type/runtime gaps are red. + +- [ ] **Step 4: Implement provider-aware create contract** + +In `shared/ws-protocol.ts`, extend `FreshAgentCreateSchema`: + +- Add `sessionRef: SessionLocatorSchema.optional()`. +- Add `modelSelection: AgentChatModelSelectionSchema.optional()` or the local shared equivalent already used for pane settings. +- Change `effort` from a fixed Codex enum to a trimmed non-empty string. Provider adapters can reject unsupported values later, but the shared fresh-agent transport must not reject Claude dynamic efforts. +- Add optional `sessionRef` to `freshAgent.created` responses if it is not already present, but only as an explicitly supplied durable identity from the runtime manager/adapter. Do not require clients to infer portability from raw `sessionId`. + +Update exported `FreshAgentCreateRequest` types in `server/fresh-agent/runtime-adapter.ts` to match. + +In `src/components/fresh-agent/FreshAgentView.tsx`, include `sessionRef` and `modelSelection` in `buildCreateMessage()`. Keep `model` for Codex runtime model selection. + +In `src/components/panes/PaneContainer.tsx`, update new-pane content creation so Claude-backed `fresh-agent` panes are initialized with `modelSelection` / opaque `effort` and no runtime `model`; Freshcodex panes keep runtime `model` / `sandbox` fields and no Claude `modelSelection`. + +In `server/fresh-agent/runtime-manager.ts`, validate locators before resolving create identity: + +1. If any supplied `sessionRef.provider` differs from the runtime provider, throw a clear typed error for the WS handler. +2. If `sessionRef.provider === 'codex'`, treat any supplied `resumeSessionId` as a durable Codex thread ID; if it differs from `sessionRef.sessionId`, throw a clear conflicting-locator error. +3. If `sessionRef.provider === 'claude'` and `resumeSessionId` is a canonical durable id that differs from `sessionRef.sessionId`, throw a clear conflicting-locator error. +4. If `resumeSessionId` is a non-canonical same-server Claude alias and `sessionRef` is present, allow live attach with `resumeSessionId` but keep `sessionRef` as the durable identity. +5. If `resumeSessionId` is present and locator consistency passed, use it for same-server live resume. +6. Else if `sessionRef` is present, use `sessionRef.sessionId`. +7. Else create a new session. + +Do not silently ignore mismatched locators. + +In `server/ws-handler.ts`, extend the pending create/cache record so it stores and replays the durable `sessionRef` from the original create result. The idempotent duplicate-request path must not reconstruct `freshAgent.created` from only `sessionId`, `sessionType`, and `runtimeProvider`. + +Also update `ui.layout.sync` session extraction so canonical `fresh-agent.sessionRef` is advertised first, and same-server runtime handles are used only for live local tracking when no durable identity exists. This boundary must not publish or dedupe remote sessions by stale `fresh-agent.resumeSessionId` when a `sessionRef` is present. + +In `src/lib/ws-client.ts`, keep reliable create tracking payloads immutable and complete. Store the original `freshAgent.create` message shape, minus only socket-local bookkeeping, and replay that complete message after reconnect. Do not reconstruct create payloads from `requestId`, `provider`, or stale pane content. + +In `server/fresh-agent/adapters/claude/adapter.ts`, resolve `input.modelSelection` before calling the SDK bridge. Prefer an existing shared resolver if one exists; otherwise extract a server-safe helper whose contract is covered by `claude-adapter.test.ts`. The adapter, not `FreshAgentView`, owns converting `modelSelection` to the SDK `model` field so FreshClaude/FreshAgent behavior stays consistent across REST/WS callers. + +In the adapter/runtime-manager create result, distinguish runtime handle from portable identity. Codex adapter results should carry `sessionRef: { provider: 'codex', sessionId: threadId }`; Claude adapter results should carry `sessionRef` only when canonical CLI/timeline history metadata is available, never from the SDK bridge's generated runtime handle. + +- [ ] **Step 5: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx +npm run test:server -- --run \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add \ + shared/ws-protocol.ts \ + server/fresh-agent/runtime-adapter.ts \ + server/fresh-agent/runtime-manager.ts \ + server/fresh-agent/adapters/claude/adapter.ts \ + server/fresh-agent/adapters/codex/adapter.ts \ + server/ws-handler.ts \ + test/server/ws-tabs-registry.test.ts \ + src/components/panes/PaneContainer.tsx \ + src/components/fresh-agent/FreshAgentView.tsx \ + src/lib/ws-client.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts +git commit -m "Support provider-aware fresh-agent create" +``` + +Omit `server/ws-handler.ts`, `server/fresh-agent/adapters/codex/adapter.ts`, and `test/server/ws-tabs-registry.test.ts` if they did not change. + +### Task 3: Fix Persisted Pane And Storage-Key Migration + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts` +- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts` +- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts` + +- [ ] **Step 1: Identify the failing tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/storage-migration.test.ts +npm run test:server -- --run \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts +``` + +If `test/unit/client/store/paneTreeValidation.test.ts` already exists when execution starts, include it in this red run. If it does not exist, create it in Step 2 before adding it to Step 3; the red phase must fail on validation behavior, not on a missing test module. + +Expected before changes: stale Claude `model` survives migration and v2 storage keys are not safely migrated/cleared. + +- [ ] **Step 2: Strengthen persistence tests** + +In `test/unit/client/store/panesPersistence.test.ts`, extend the legacy model test so it covers the actual post-parse path that converts legacy `agent-chat` to `fresh-agent`. Assert: + +```ts +expect(content.kind).toBe('fresh-agent') +expect(content.sessionType).toBe('freshclaude') +expect(content.provider).toBe('claude') +expect(content.model).toBeUndefined() +expect(content.modelSelection).toEqual({ + kind: 'exact', + modelId: 'fixture-claude-model', +}) +``` + +Add a sibling Codex regression: + +```ts +expect(content.kind).toBe('fresh-agent') +expect(content.provider).toBe('codex') +expect(content.model).toBe('codex-model') +expect(content.modelSelection).toBeUndefined() +``` + +Add or extend cross-tab/persisted hydration coverage so `parsePersistedLayoutRaw()` / `parsePersistedPanesRaw()` / `hydratePanes()` cannot keep stale Claude `model` via a path that bypasses `loadPersistedPanes()`. This must cover `src/store/persistedState.ts`, because storage migration and cross-tab sync use that parser boundary directly. + +Add persisted runtime-handle coverage: + +- A fresh-agent pane with `sessionRef` plus `sessionId` / `resumeSessionId` persists with `sessionRef` and without same-server runtime handles. +- A same-server runtime-only Claude-backed fresh-agent pane with current `serverInstanceId` and no `sessionRef` remains locally resumable instead of being converted to `restoreError`. +- A localStorage payload restored before WebSocket `ready` with a stale `serverInstanceId` is reconciled after the current server instance is known; it either switches to durable `sessionRef` resume or displays `restoreError`, and it never auto-creates a new unrelated session. +- The same runtime-only pane copied/published as a remote/cross-server payload drops runtime handles and receives `restoreError`. +- A restored persisted pane with only `sessionRef` remains in a lifecycle state that will resume through `freshAgent.create` rather than being treated as already connected. +- A rich-agent pane with neither `sessionRef` nor same-server handles gets a `restoreError` and does not become a new-session create on reload. + +In `test/unit/client/store/crossTabSync.test.ts`, assert the same persistence rules apply to incoming storage events and cross-tab writeback: no stale runtime handle survives after `sessionRef` exists, runtime-only handles survive only for the current `serverInstanceId`, and stale handles reconcile after the ready message provides the current server id. + +In `test/unit/client/store/tabRegistrySync.test.ts`, add retained-closed-record publication coverage. Seed `state.tabRegistry.localClosed` with legacy `agent-chat` content and runtime-only `fresh-agent` handles, then assert the published registry payload is canonicalized just like open tabs. + +In `test/unit/server/agent-layout-schema.test.ts` and `test/unit/server/agent-api/layout-store.fresh-agent.test.ts`, add server-side layout ingress coverage: `ui.layout.sync` / layout-store writes with rich-agent pane content are validated/canonicalized on write/read, not stored as opaque `z.record(z.any())` payloads that can later expose stale identity. + +In `test/unit/client/store/paneTreeValidation.test.ts`, assert `isWellFormedPaneTree()` accepts a fresh-agent Claude pane with a valid durable identity: + +```ts +{ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'idle', + effort: 'turbo', + modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' }, + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, +} +``` + +Assert it separately accepts an un-restorable pane with only `restoreError`: + +```ts +{ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'create-failed', + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' }, +} +``` + +Also assert malformed `sessionRef`, malformed `restoreError`, malformed `modelSelection`, non-string `effort`, and the invalid combination of `sessionRef` plus `restoreError` are rejected. + +In `test/unit/client/store/storage-migration.test.ts`, strengthen v2 migration coverage: + +- Old-version path: `freshell.tabs.v2` / `freshell.panes.v2` become `freshell.layout.v3`, and v2 keys are removed. +- Already-stamped broken path: `freshell_version` already equals the old branch version, stale v2 keys remain, and the repair still runs because `STORAGE_VERSION` is bumped. +- Corrupt-layout salvage path: an invalid `freshell.layout.v3` plus valid v2 keys writes a valid v3 layout before removing v2 keys. +- Valid-layout path: an existing valid v3 layout is not overwritten by stale v2 keys, and stale v2 keys are removed. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/storage-migration.test.ts \ + test/unit/client/store/paneTreeValidation.test.ts +npm run test:server -- --run \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts +``` + +Expected: failures point to stale provider model cleanup and storage repair gaps. + +- [ ] **Step 4: Implement provider-specific pane persistence migration** + +In `persistMiddleware.ts` and `persistedState.ts`, use the same provider-specific model normalization contract as `panesSlice.ts`: + +- `agent-chat`: migrate `model` into `modelSelection`, omit `model`. +- `fresh-agent` with `provider === 'claude'`: migrate `model` into `modelSelection`, omit `model`. +- `fresh-agent` with `provider === 'codex'`: preserve runtime `model`, omit stale `modelSelection`, preserve valid Codex runtime fields. + +Update `stripTransientSessionFields()` so fresh-agent persisted/cross-tab payloads keep `sessionRef` but strip same-server-only `resumeSessionId` when a durable `sessionRef` exists or when the payload is crossing a server boundary. Do not strip portable `sessionRef`. + +Also strip same-server `sessionId` for persisted/cross-tab fresh-agent payloads when a durable `sessionRef` exists or the source server does not match the current server. For a current-server runtime-only Claude pane that has no `sessionRef` yet, keep the runtime handle and `serverInstanceId` only in local same-server storage/cross-tab state so reloads during the same server lifetime can attach; never publish those fields to tab-registry/remote copies. + +In `persistedState.ts`, normalize parsed pane content before it is returned to callers. The invariant is that any persisted, cross-tab, or migrated rich-agent payload leaving this parser is already canonical: legacy `agent-chat` has become `fresh-agent`, Claude `model` has been migrated to `modelSelection` and removed, Codex runtime `model` is preserved, and invalid provider/session fields are stripped. + +In `crossTabSync.ts`, call that same parser/canonicalizer for inbound and outbound payloads. Add a post-ready reconciliation path for local restored panes whose runtime handles were accepted provisionally before the current `serverInstanceId` was available. + +In `tabRegistrySync.ts`, canonicalize retained `localClosed` records before publishing them. Do not assume records captured before this branch are already sanitized. + +In `server/agent-api/layout-schema.ts` and `server/agent-api/layout-store.ts`, apply the same rich-agent canonicalization at the server layout boundary. The schema can remain forward-compatible for non-rich pane fields, but rich-agent content must not pass through as arbitrary opaque JSON when it contains legacy identity or runtime handles. + +In `paneTreeValidation.ts`, align validation with the canonical content shape: + +- validate `fresh-agent.sessionRef` and `fresh-agent.restoreError` using the same shape checks as terminal/legacy agent-chat panes; +- reject `fresh-agent` content that contains both a valid `sessionRef` and `restoreError`; a valid durable identity clears the stale restore error before persistence; +- validate `fresh-agent.modelSelection` with `isAgentChatModelSelection`; +- accept opaque non-empty string `effort` values for Claude-backed panes while still rejecting non-string effort values; +- keep Codex `sandbox` enum validation. + +- [ ] **Step 5: Implement idempotent storage repair** + +In `storage-migration.ts`: + +- Bump `STORAGE_VERSION` so clients already stamped by the broken branch run the repair. +- Import `TABS_STORAGE_KEY`, `PANES_STORAGE_KEY`, and `migrateV2ToV3` from the existing storage modules. +- Attempt to parse/migrate existing `LAYOUT_STORAGE_KEY`. +- If the layout key is absent or corrupt and v2 tabs/panes are recoverable, call `migrateV2ToV3()` and write the v3 layout before deleting v2 keys. +- If the layout key is valid, do not overwrite it from stale v2 keys; remove v2 keys as stale compatibility keys. +- Remove v2 keys only after either a valid layout exists or v2 data is unrecoverable. +- Keep auth and browser-preference migration behavior unchanged. + +The invariant is: recoverable v2 data is written to `freshell.layout.v3` before `freshell.tabs.v2` / `freshell.panes.v2` are removed. + +- [ ] **Step 6: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/storage-migration.test.ts \ + test/unit/client/store/paneTreeValidation.test.ts +npm run test:server -- --run \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + src/store/persistMiddleware.ts \ + src/store/persistedState.ts \ + src/store/crossTabSync.ts \ + src/store/tabRegistrySync.ts \ + src/store/storage-migration.ts \ + src/store/paneTreeValidation.ts \ + server/agent-api/layout-schema.ts \ + server/agent-api/layout-store.ts \ + src/lib/pane-content.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/storage-migration.test.ts \ + test/unit/client/store/paneTreeValidation.test.ts \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts +git commit -m "Repair fresh-agent persistence migrations" +``` + +Omit `src/lib/pane-content.ts` if it was not created. + +### Task 4: Normalize Fresh-Agent Settings Aliases And Clear Sentinels + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts` +- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts` + +- [ ] **Step 1: Identify the failing tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/shared/settings.test.ts +npm run test:server -- --run \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/integration/server/settings-api.test.ts +``` + +Expected before changes: client clear-sentinel payloads include own `undefined` fields or server tests still expect obsolete `{ defaultModel: 'x' }`. + +- [ ] **Step 2: Update tests to the canonical settings contract** + +In `test/unit/client/store/settingsThunks.test.ts`, add a recursive helper: + +```ts +function expectNoUndefinedOwnProperties(value: unknown, path = 'payload'): void { + if (!value || typeof value !== 'object') return + for (const [key, child] of Object.entries(value as Record<string, unknown>)) { + expect(child, `${path}.${key}`).not.toBeUndefined() + expectNoUndefinedOwnProperties(child, `${path}.${key}`) + } +} +``` + +Use this helper on the exact payload passed to `api.patch`. Do not rely on `JSON.stringify()`, because JSON drops `undefined` object properties. + +Assert clear sentinels use explicit `null` values: + +```ts +expect(apiPatch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ + agentChat: { + providers: { + freshclaude: { + modelSelection: null, + effort: null, + }, + }, + }, +})) +expectNoUndefinedOwnProperties(apiPatch.mock.calls[0][1]) +``` + +If the caller intentionally sends both `freshAgent` and `agentChat`, assert both aliases have the same normalized null sentinels. If the caller sends only one alias, assert the normalizer does not create a top-level sibling alias with value `undefined`. + +In `test/unit/client/store/settingsSlice.test.ts`, add reducer-ingestion coverage for `setServerSettings` and `previewServerSettingsPatch`: hydrated server settings, optimistic patches, and clear sentinels produce the same canonical `freshAgent` / `agentChat` aliases and resolved provider settings that the API route returns. This is required because route/thunk fixes alone do not prove UI selectors see canonical state. + +In `test/unit/server/config-store.fresh-agent-settings.test.ts`, replace the legacy expectation with canonical mirrored settings: + +```ts +expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) +expect(settings.agentChat.defaultPlugins).toEqual(['/tmp/plugin']) +expect(settings.freshAgent.providers.freshclaude).toEqual({ + modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' }, + effort: 'high', +}) +expect(settings.agentChat.providers.freshclaude).toEqual({ + modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' }, + effort: 'high', +}) +``` + +In `test/unit/server/config-store.test.ts`, add or strengthen a real `ConfigStore.load()` legacy-config test. Persist a version-1 config with legacy `agentChat.providers.freshclaude.defaultModel/defaultEffort` and assert the loaded config has canonical mirrored `settings.freshAgent` and `settings.agentChat`. This is required because direct `mergeServerSettings()` tests do not prove file-load compatibility. + +Add two more `ConfigStore.load()` compatibility cases: + +- a legacy config that contains only `freshAgent.providers.freshclaude.defaultModel/defaultEffort` also loads into canonical mirrored `freshAgent` and `agentChat` settings; +- a conflict config containing both aliases uses the explicitly documented precedence from `shared/settings.ts` and proves the lower-precedence alias does not overwrite a newer canonical `modelSelection` / `effort`. + +In `test/integration/server/settings-api.test.ts`, add route-level coverage for `PATCH /api/settings` that sends `freshAgent.providers.freshclaude.modelSelection: null` and `effort: null`. Assert the route accepts the patch, clears the provider defaults, mirrors compatibility aliases as intended, and does not reintroduce `undefined` provider keys. Keep the existing legacy `agentChat` clear-sentinel test green. + +While touching these tests, replace real provider-looking model identifiers with neutral fixture identifiers such as `fixture-claude-model`, `fixture-codex-model`, `fixture-tracked-model`, `fixture-unavailable-model`, and `fixture-generic-provider-model`. This stabilization work tests pass-through and migration behavior only; it must not pin or discuss current provider model names. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/shared/settings.test.ts +npm run test:server -- --run \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/integration/server/settings-api.test.ts +``` + +Expected: tests fail until client patch normalization and server load compatibility are aligned. + +- [ ] **Step 4: Implement API patch normalization without top-level undefined pollution** + +In `settingsThunks.ts`, normalize only sections that exist as records: + +```ts +function normalizeAgentProviderDefaultsPatchForApiSection(section: unknown): unknown { + if (!isRecord(section) || !isRecord(section.providers)) return section + return { + ...section, + providers: Object.fromEntries( + Object.entries(section.providers).map(([providerName, providerPatch]) => [ + providerName, + isRecord(providerPatch) ? normalizeAgentChatProviderPatchForApi(providerPatch) : providerPatch, + ]), + ), + } +} +``` + +Assign back only when the original key exists: + +```ts +if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'freshAgent')) { + normalizedPatch.freshAgent = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.freshAgent) +} +if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'agentChat')) { + normalizedPatch.agentChat = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.agentChat) +} +``` + +After alias-specific normalization, run a general recursive sanitizer over the outgoing API patch: + +- remove own properties whose value is `undefined` when the key has no defined clear sentinel; +- convert known clearable agent-provider fields such as `modelSelection`, `effort`, and provider default fields to `null`; +- recurse through nested objects and arrays without mutating the caller's input; +- never create a top-level `freshAgent` or `agentChat` sibling just to hold `undefined` children. + +Preserve existing clear behavior for coding CLI provider fields. The final payload passed to `api.patch()` must satisfy `expectNoUndefinedOwnProperties()` at every depth, not only inside `freshAgent.providers`. + +In `settingsSlice.ts`, route `setServerSettings`, optimistic preview state, and reducer-level patch ingestion through the shared canonical settings sanitizer. The reducer must not keep stale `defaultModel`, stale `defaultEffort`, or provider entries with own `undefined` values after server hydration. + +In `server/settings-router.ts`, route-level normalization must handle `freshAgent.providers.*` clear sentinels in addition to the legacy `agentChat` alias. The HTTP route is part of the contract; do not rely solely on lower-level `shared/settings.ts` unit tests. + +- [ ] **Step 5: Verify shared/server settings migration** + +In `shared/settings.ts`, ensure the existing sanitization/merge path maps: + +- `defaultModel` to `modelSelection: { kind: 'exact', modelId }` +- `defaultEffort` to `effort` +- legacy `agentChat` input to mirrored `freshAgent` and `agentChat` +- legacy `freshAgent` input to mirrored `freshAgent` and `agentChat` +- both aliases present with conflicting provider defaults to the same documented precedence covered by `ConfigStore.load()` tests + +Prefer fixing `shared/settings.ts` over patching `server/config-store.ts`; `ConfigStore.load()` should become green by using the shared contract. + +- [ ] **Step 6: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/shared/settings.test.ts +npm run test:server -- --run \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/integration/server/settings-api.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + shared/settings.ts \ + server/config-store.ts \ + server/settings-router.ts \ + src/store/settingsThunks.ts \ + src/store/settingsSlice.ts \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/integration/server/settings-api.test.ts \ + test/unit/shared/settings.test.ts +git commit -m "Normalize fresh-agent settings compatibility" +``` + +Omit `server/config-store.ts` if it did not change. + +### Task 5: Make Fresh-Agent Recovery Stale-Update Safe + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/fresh-agent-ws.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts` + +- [ ] **Step 1: Identify the failing tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts +``` + +Expected before changes: lost-session recovery can use `named-resume` instead of the canonical durable ID, stale async updates can overwrite newer pane fields, remote restore errors can still auto-create a replacement session, and sessionRef-only resumes may not hydrate history correctly. + +- [ ] **Step 2: Strengthen recovery and stale-update tests** + +In `AgentChatView.session-lost.test.tsx`, keep the existing canonical assertion and add: + +```ts +expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + resumeSessionId: 'named-resume', +})) +``` + +Add a fallback test where no valid `timelineSessionId` or `cliSessionId` exists and assert `named-resume` is still used. The fix must not break legitimate non-canonical same-server resumes. + +In `AgentChatView.reload.test.tsx` and `AgentChatView.split-pane.test.tsx`, replace old assertions that a persisted FreshAgent pane sends `freshAgent.attach` from stripped runtime handles. Assert a pane with only `sessionRef` resumes through the idempotent `freshAgent.create` path, while a current-server runtime-only pane with matching `serverInstanceId` can still use the live same-server handle. + +In `FreshAgentView.test.tsx`, add stale-update coverage for these cases: + +- `paneContent.restoreError` is rendered as a clear user-facing error and suppresses automatic `freshAgent.create`; +- `freshAgent.created` merges `sessionId`, `status`, and `createError` without clobbering a newer `model`, `modelSelection`, `permissionMode`, `plugins`, `sessionRef`, or `settingsDismissed`. +- `freshAgent.created` for a newly created durable Freshcodex session writes `sessionRef: { provider: 'codex', sessionId: message.sessionId }` before the pane can be persisted without `resumeSessionId`. +- `freshAgent.created` for a Claude-backed pane does not write `sessionRef` from `message.sessionId` unless the message contains an explicit trusted `sessionRef` or canonical history metadata. +- `freshAgent.create.failed` merges `status` and `createError` without clobbering newer fields. +- snapshot refresh merges `status` / canonical resume identity without clobbering newer pane settings. +- retry/recovery resets lifecycle fields but preserves current provider-specific settings and writes canonical `sessionRef`. +- when a canonical Claude durable ID appears after a named resume, the pane gets `sessionRef: { provider: 'claude', sessionId }`, clears or deprioritizes the named resume for durable persistence as appropriate, and dispatches `flushPersistedLayoutNow()`. +- a Freshcodex pane with `sessionRef` and no `resumeSessionId` recovers using `paneContentRef.current.sessionRef.sessionId` rather than falling through to a stale named alias or failing to resume. +- a pane that gains a valid `sessionRef` clears any stale `restoreError` in the same merge so the error does not suppress the next legitimate recovery. + +In `fresh-agent-ws.test.ts`, add a sessionRef-only restore case: + +```ts +registerFreshAgentCreate(dispatch, 'req-1', { + sessionType: 'freshcodex', + provider: 'codex', + sessionRef: { provider: 'codex', sessionId: 'codex-thread-123' }, +}) +``` + +Assert pending-create state is marked as expecting history hydration even though `resumeSessionId` is absent. This prevents remote copied panes that intentionally drop runtime handles from being treated as new empty sessions. + +In `freshAgentSlice.test.ts`, assert the reducer-level result, not just pending-create metadata: a `freshAgent.created` transition from a pending create with `expectsHistoryHydration: true` creates/updates session state with `historyLoaded: false` and `awaitingDurableHistory: true`. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts +``` + +Expected: tests fail until recovery uses fresh refs and async handlers stop dispatching whole captured pane objects. + +- [ ] **Step 4: Implement canonical recovery identity** + +In `FreshAgentView.tsx`, track the latest preferred resume ID in a ref: + +```ts +const preferredResumeSessionIdRef = useRef<string | undefined>(preferredResumeSessionId) +preferredResumeSessionIdRef.current = preferredResumeSessionId +``` + +Update recovery to compute: + +```ts +const recoveryResumeSessionId = + (paneContentRef.current.sessionRef?.provider === paneContentRef.current.provider + ? paneContentRef.current.sessionRef.sessionId + : undefined) + ?? preferredResumeSessionIdRef.current + ?? getPreferredResumeSessionId(claudeSessionRef.current) + ?? paneContentRef.current.resumeSessionId +``` + +When `recoveryResumeSessionId` comes from `sessionRef`, keep that existing provider-specific durable identity. When the fallback recovery ID is a valid canonical Claude ID, also set: + +```ts +sessionRef: { provider: 'claude', sessionId: recoveryResumeSessionId } +``` + +Use `mergePaneContent()` for targeted recovery updates unless a full replacement is genuinely required. Do not send a direct ad hoc `freshAgent.create` from recovery; recovery should continue to flow through pane state and the existing idempotent create effect. + +At the create effect boundary, add an explicit guard: + +```ts +if (paneContent.restoreError) return +``` + +Render `paneContent.restoreError` through a local helper such as `formatRestoreError(reason)` in the same visible error area as create/load/restore failures. Do not expect a message property; the shared `RestoreError` contract is `{ code, reason }`. A pane copied from another device with no portable identity is a restore failure, not a request to start a fresh unrelated session. + +- [ ] **Step 5: Implement persisted identity and no-clobber lifecycle updates** + +If needed, add a `buildFreshAgentPersistedIdentityUpdate()` helper in `persistControl.ts`, analogous to `buildAgentChatPersistedIdentityUpdate()`, for Claude-backed `fresh-agent` panes. + +In `FreshAgentView.tsx`: + +- Replace async `updatePaneContent({ content: { ...paneContent, ... } })` calls from `freshAgent.created`, `freshAgent.create.failed`, and snapshot refresh with `mergePaneContent()` targeted updates or with a freshly-read `paneContentRef.current`. +- When canonical durable identity changes, merge `sessionRef`, clear stale `restoreError`, and dispatch `flushPersistedLayoutNow()`. +- When `freshAgent.created` arrives and there is no current `sessionRef`, persist a `sessionRef` only from `message.sessionRef` or from a provider contract that explicitly declares the created id durable. Codex thread ids are durable; Claude SDK bridge runtime ids are not. This is required for new Freshcodex threads before `resumeSessionId` is stripped from persisted payloads without corrupting FreshClaude portable identity. +- Preserve current provider-specific settings on retry/recovery. +- Keep create idempotency and reconnect behavior intact. + +In `fresh-agent-ws.ts`, extend `registerFreshAgentCreate()` options to accept `sessionRef`. Set `expectsHistoryHydration` when either `resumeSessionId` exists or `sessionRef` exists. Do not weaken late-create cancellation behavior. + +In `freshAgentSlice.ts`, preserve `expectsHistoryHydration` through the reducer transition that materializes a session from pending create state. The user-visible invariant is that a restored sessionRef-only Freshcodex pane waits for durable transcript hydration instead of rendering as a brand-new empty session. + +- [ ] **Step 6: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + src/components/fresh-agent/FreshAgentView.tsx \ + src/lib/fresh-agent-ws.ts \ + src/store/freshAgentSlice.ts \ + src/store/persistControl.ts \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts +git commit -m "Harden fresh-agent recovery identity" +``` + +Omit `src/store/persistControl.ts` if it did not change. + +### Task 6: Repair Legacy AgentChat Harness And Context Menu Selectors + +**Files:** +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx` +- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx` + +- [ ] **Step 1: Identify the failing/warning tests** + +Run: + +```bash +npm run test:vitest -- --run \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +``` + +Expected before changes: the capability settings flow can render an empty DOM in full-suite conditions, and context menu selectors can emit React Redux stability warnings. + +- [ ] **Step 2: Repair the legacy AgentChatView harness** + +In `test/e2e/agent-chat-capability-settings-flow.test.tsx`, keep this file as legacy `AgentChatView` coverage. Do not convert it to `FreshAgentView`. + +Change `renderStoreBackedPane()` so the legacy component is mounted from an explicit raw `AgentChatPaneContent` prop and does not disappear when reducer effects canonicalize the backing store to `fresh-agent`. + +The harness should: + +- Seed `preloadedState.panes.layouts` with raw legacy `agent-chat` state directly, not via `initLayout()`. +- Render `AgentChatView` through a test-only adapter that always keeps the component mounted. +- Keep the prop live, not frozen: initialize from the raw legacy content, then update a local/ref-backed `AgentChatPaneContent` from store changes or reducer-dispatched pane updates by converting canonical `fresh-agent` fields back into the legacy prop shape expected by `AgentChatView`. +- Use Redux store state for dependencies and dispatch effects. +- If a test needs to inspect reducer effects, inspect `store.getState()` separately. Do not gate component rendering on `state.panes.layouts.t1.content.kind === 'agent-chat'` after the first reducer update. +- Preserve settings/retry fidelity: after a test changes model/effort settings, the next Retry click must use the updated pane settings from the reducer-backed state rather than the initial raw prop. +- Preserve the existing user-visible assertions around provider capability rows, unavailable models, settings buttons, and create failure messages. + +This fixes the empty-DOM symptom at the correct level: the legacy component test harness should not use a production canonicalization boundary as its render predicate. + +- [ ] **Step 3: Add context menu warning regression coverage** + +In `ContextMenuProvider.test.tsx`, spy on `console.warn`, render `ContextMenuProvider` with a store state where optional selector sources such as `connection.featureFlags` are absent, dispatch an unrelated action or rerender, and assert no React Redux selector instability warning appears. + +Use a narrow assertion: + +```ts +expect(consoleWarnSpy.mock.calls.map((call) => String(call[0])).join('\n')).not.toContain('Selector') +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: + +```bash +npm run test:vitest -- --run \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +``` + +Also run the smallest known order-sensitive batch that previously exposed the empty-DOM symptom: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/store/panesSlice.test.ts \ + test/e2e/agent-chat-capability-settings-flow.test.tsx +``` + +Expected: harness/warning gaps are red or reproduce. If the standalone file is green but the combined batch fails, keep the combined batch as the red check for this task. + +- [ ] **Step 5: Implement stable selector fallbacks** + +In `ContextMenuProvider.tsx`, replace inline object/array fallbacks in `useAppSelector()` with module-level constants: + +```ts +const EMPTY_FEATURE_FLAGS: Record<string, boolean> = {} +const EMPTY_ARRAY: readonly unknown[] = [] +``` + +Use specific typed constants instead of allocating `{}` or `[]` inside selectors. Review nearby selectors and fix every inline fallback that can return a new reference on each selector call. + +- [ ] **Step 6: Refactor and verify** + +Run: + +```bash +npm run test:vitest -- --run \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +``` + +Run the combined order-sensitive batch again: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/store/panesSlice.test.ts \ + test/e2e/agent-chat-capability-settings-flow.test.tsx +``` + +Expected: all selected tests pass and no selector instability warning is emitted. + +- [ ] **Step 7: Commit** + +```bash +git add \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + src/components/context-menu/ContextMenuProvider.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +git commit -m "Stabilize legacy agent-chat harness" +``` + +### Task 7: Full Freshcodex/Fresh-Agent Regression Verification + +**Files:** +- Modify only if failures expose real defects in already touched Freshcodex/fresh-agent code. + +- [ ] **Step 1: Run the complete known-failure subset** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/components/TabsView.test.tsx \ + test/unit/client/components/TabsView.fresh-agent.test.tsx \ + test/unit/client/components/TabContent.test.tsx \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/lib/tab-fallback-identity.test.ts \ + test/unit/client/lib/session-utils.test.ts \ + test/unit/shared/session-contract.test.ts \ + test/unit/client/lib/claude-session-id.test.ts \ + test/unit/client/lib/session-type-utils.test.ts \ + test/unit/client/ui-commands.test.ts \ + test/unit/client/store/paneTreeValidation.test.ts \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/client/store/tabsSlice.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/client/store/selectors/sidebarSelectors.test.ts \ + test/unit/client/store/settingsSlice.test.ts \ + test/unit/client/store/settingsThunks.test.ts \ + test/unit/client/store/storage-migration.test.ts \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \ + test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/lib/ws-client.test.ts \ + test/unit/client/lib/pane-activity.test.ts \ + test/unit/client/store/freshAgentSlice.test.ts \ + test/e2e/agent-chat-capability-settings-flow.test.tsx \ + test/unit/client/components/ContextMenuProvider.test.tsx +``` + +Run: + +```bash +npm run test:server -- --run \ + test/unit/server/config-store.fresh-agent-settings.test.ts \ + test/unit/server/config-store.test.ts \ + test/unit/server/agent-layout-schema.test.ts \ + test/unit/server/agent-api/layout-store.fresh-agent.test.ts \ + test/unit/server/mcp/freshell-tool.test.ts \ + test/unit/server/terminal-registry.test.ts \ + test/unit/server/fresh-agent/claude-adapter.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/integration/server/settings-api.test.ts \ + test/server/ws-tabs-registry.test.ts +``` + +Expected: all pass. + +- [ ] **Step 2: Run adjacent Freshcodex/fresh-agent regression suites** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/client/lib/fresh-agent-ws.test.ts \ + test/unit/client/sdk-message-handler.test.ts \ + test/unit/client/ws-client-sdk.test.ts \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/components/panes/PaneContainer.test.tsx +``` + +Run: + +```bash +npm run test:server -- --run \ + test/unit/server/fresh-agent/codex-adapter.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/coding-cli/codex-app-server/client.test.ts \ + test/unit/server/coding-cli/codex-app-server/runtime.test.ts +``` + +Expected: all pass. If any fail, fix the real defect or update obsolete expectations only when the new assertion is stronger and matches the fresh-agent contract. + +- [ ] **Step 3: Run browser Fresh Agent smoke** + +Run: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts +npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent-mobile.spec.ts +``` + +Expected: all tests pass. + +- [ ] **Step 4: Run typecheck, lint, and diff hygiene** + +Run: + +```bash +npm run typecheck +npm run lint +npm run build +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 5: Run the full coordinated suite** + +Run: + +```bash +FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check +``` + +Expected: all pass. If failures are reported, classify them: + +- If they touch fresh-agent, freshcodex, legacy agent-chat compatibility, settings, pane persistence, storage migration, remote tab rehydration, or context menu warnings, continue fixing them in this plan. +- If they are genuinely unrelated pre-existing failures, do not ignore them silently. Record exact tests and evidence, then stop with a clear blocker report. + +- [ ] **Step 6: Commit final verification fixes if needed** + +If Step 5 required additional code/test changes, commit them: + +```bash +git add <changed files> +git commit -m "Close freshcodex full-suite regressions" +``` + +If no files changed after the earlier task commits, do not create an empty commit. + +### Task 8: Final Review Handoff + +**Files:** +- No required file changes. + +- [ ] **Step 1: Inspect final diff** + +Run: + +```bash +git status --short +git diff --check +git log --oneline --max-count=8 +git diff --stat origin/dev...HEAD +``` + +Expected: worktree clean except intentionally uncommitted user work if any appears during execution; no whitespace errors. + +- [ ] **Step 2: Summarize resolved issues** + +Prepare the implementation report with: + +- Current `HEAD` +- Each known blocker and the file/test that now proves it fixed +- Exact commands run and pass/fail status +- Any residual failures or explicitly unrelated blockers, with paths and evidence + +- [ ] **Step 3: Do not land to dev/main automatically** + +Stop after implementation and verification. The conductor/user will decide whether to run another review loop, squash, or integrate into `dev`. diff --git a/docs/rca/2026-05-09-opencode-ambiguous-session-ownership.md b/docs/rca/2026-05-09-opencode-ambiguous-session-ownership.md new file mode 100644 index 000000000..f7e3411bc --- /dev/null +++ b/docs/rca/2026-05-09-opencode-ambiguous-session-ownership.md @@ -0,0 +1,83 @@ +# RCA: OpenCode Ambiguous Session Ownership Warnings + +**Date:** 2026-05-09 +**App:** Freshell v0.7.0 (production) +**Component:** `opencode-activity-tracker` +**Severity:** WARN (but functionally correctness-affecting — suppresses durable session adoption) + +## Problem + +Repeated WARN log entries: + +``` +OpenCode endpoint reported ambiguous session ownership; suppressing durable adoption. +``` + +Each warning lists multiple `sessionIds` returned by the OpenCode endpoint for a given `terminalId`. Six root cause analysis agents across two adversarial rounds investigated. + +## Root Cause (Two-Part) + +### 1. Trigger: Multi-Session Architecture Mismatch (90% confidence) + +OpenCode v1.14.44 creates **multiple concurrently `busy` sessions per process** when launched without the `--session` flag. Freshell's ownership reducer (`server/coding-cli/opencode-ownership-reducer.ts`) assumes exclusive session ownership (one terminal = one session). When fresh terminals launch OpenCode without `--session`, the endpoint returns 2–8 concurrent busy sessions, which the reducer interprets as competing ownership claims. This triggers the `ambiguous` state and the warning. + +**Code trace:** +- `server/terminal-registry.ts:252-259` — only sets `resumeArgs` when `resumeSessionId` is truthy +- `server/coding-cli/providers/opencode.ts:137-138` — maps `resumeSessionId` → `['--session', sessionId]` +- Fresh terminals have no `resumeSessionId` → no `--session` flag → OpenCode treats all sessions as candidates + +**Differential evidence:** +- Processes WITH `--session`: report 0–1 busy sessions (works correctly) +- Processes WITHOUT `--session`: report 2–8 busy sessions (triggers ambiguity) +- All ambiguous sessions verified as `type: "busy"` via live endpoint query (not `retry`) + +### 2. Amplifier: Stale Session Accumulation Bug (85–95% confidence) + +`reduceSnapshot` at `server/coding-cli/opencode-ownership-reducer.ts:281` computes blocked sessions as: + +```ts +uniqueSorted([...state.blockedSessionIds, ...busySessionIds]) +``` + +This is a pure UNION — it never prunes sessions that have completed. Stale session IDs from prior SSE stream intervals are permanently trapped. The state can never self-resolve from `ambiguous`. Zero of 21 affected terminals have ever recovered. + +### Falsified Hypothesis + +The initial theory that `retry`-status sessions were being conflated with `busy` in `sortedBusySessionIds` (line 92–97: `status.type !== 'idle'`) was **falsified by live system evidence**. A query of all production endpoints found zero `retry` sessions — all multi-busy sessions are `type: "busy"`. Changing the filter to `=== 'busy'` would be a no-op for the observed warnings. The schema defines `retry` as a valid type, but OpenCode v1.14.44 never emits it. + +## Recommendation + +**Three concrete changes** — prefer fixing at the source (constrain OpenCode to single-session) over downstream mitigation: + +1. **`server/terminal-registry.ts:252-259`** — When `mode === 'opencode'` and `resumeSessionId` is undefined, generate a session ID (e.g. via `nanoid()`) and pass it as `resumeArgs`. This constrains OpenCode to single-session mode. + +2. **`server/coding-cli/opencode-activity-wiring.ts:61-68`** — Pass the generated session ID to `trackTerminal` so the reducer receives a `knownSessionId` hint, enabling `quiet → knownBusy` instead of `quiet → ambiguous`. + +3. **`server/coding-cli/opencode-ownership-reducer.ts:281`** — Replace the UNION with `busySessionIds` (recompute from snapshot). The snapshot is authoritative for current state. + +**Prerequisite verification:** Confirm `opencode serve --session <id>` constrains to single-session behavior. + +**Rollback mitigation:** Revert commits `29dc693c` and `c1f76b1f` to return to v0.6.0 `extractBusySessionId()` behavior (no ambiguity detection, no warnings). + +## RCA Process Meta-Analysis + +### What worked + +The adversarial multi-agent approach surfaced insights a single analyst would have missed. The most important finding — that OpenCode reports 4 concurrently `busy` sessions — came from the Outsider agent querying the live production system, breaking through a false consensus that two other agents had converged on with 85% confidence. Without the adversarial structure, the likely outcome would have been changing a filter to `=== 'busy'` — a no-op fix. + +### What was the evidence gap + +The single largest evidence gap across both runs was **no runtime evidence in Run 1**. All six agents in the first run performed static code analysis only. The 10–18% confidence gap was entirely attributable to missing live system data. Run 2's agents corrected this by querying production endpoints. + +### Key process finding + +Evidence diversity drives output diversity more than perspective framing does. Agents working from the same evidence (code + logs) converged even with different perspectives. Agents with different evidence (live system probes) diverged productively. + +### Process improvements identified + +1. Assign different evidence-gathering domains, not just different thinking styles +2. Require live-system verification for production-observable issues +3. Add falsification as an explicit round between evidence gathering and synthesis +4. Require agents to enumerate unverified assumptions with separate confidence ratings +5. Prevent redundant evidence gathering in Round 2 via task assignment +6. "Assumption Auditor" perspective — agent whose sole job is listing and verifying assumptions diff --git a/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md b/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md new file mode 100644 index 000000000..488906e28 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md @@ -0,0 +1,1063 @@ +# Tabs Registry Compact State Plan + +Status: revised plan after second adversarial review, based on `dev` at `71c0542d` +Worktree: `.worktrees/tabs-registry-device-snapshots-dev` +Branch: `feature/tabs-registry-device-snapshots-dev` + +## Summary + +The root problem is not that the Tabs view has too many visible tabs. The root problem is that the server persists every tab sync event forever in `~/.freshell/tabs-registry/tabs-registry.jsonl`, then reads and splits that whole file on startup. + +Measured evidence from the production incident: + +- `tabs-registry.jsonl`: about 291 MiB, 438,708 lines, about 1,367 unique tab keys. +- Standalone hydrate benchmark: about 1.14 GiB heap used during hydrate. +- Restart logs: first production heap sample about 1.275 GiB. +- Current code path: + - `server/tabs-registry/store.ts` reads the whole JSONL file in the constructor. + - `server/tabs-registry/store.ts` appends every accepted record. + - `server/ws-handler.ts` handles `tabs.sync.push` by upserting each record one by one. + +The correct fix is to remove the append-only event log from the active path and replace it with compact bounded state. + +The initial "one snapshot per device" design is not safe. A Freshell device identity is persisted in `localStorage`, so multiple browser windows on the same machine share the same `deviceId`. If the server treats a whole-device snapshot as authoritative, a stale hidden window can erase tabs from an active window. The revised design separates ownership: + +- Open tabs are replaceable per running browser instance, not per device. +- Closed tabs are retained as merged tombstones, not deleted by omission. +- Devices are grouped for display, but they are not the write-concurrency boundary. + +## Plain-English Model + +There are three identities: + +1. Device + - A durable local-machine identity stored in browser storage. + - Used for display grouping: "This machine", "Studio Mac", etc. + - Multiple browser windows can share it. + +2. Client instance + - A short-lived identity for one Freshell browser window. + - Stored in `sessionStorage`, so a reload keeps replacing the same window-owned snapshot. + - A separate browser window gets a separate client instance. + - This is the ownership boundary for open-tab snapshots. + +3. Tab key + - The stable key for one tab record. + - Used to dedupe competing open/closed records with last-write-wins semantics. + +The server stores compact state: + +- `openSnapshotsByClient`: latest open snapshot from each active browser instance. +- `closedByTabKey`: latest closed tombstone for each recently closed tab. +- `devicesById`: recent device metadata based on server receipt time, used only for device display and naming. + +On query, the server combines fresh open snapshots with retained closed tombstones for conflict resolution, filters closed winners to the requested retention window, and returns: + +- `localOpen` +- `sameDeviceOpen` +- `remoteOpen` +- `closed` + +This keeps the small useful state while removing the unbounded historical journal. + +## Strategic Decisions + +### 1. Open State Is Authoritative Only Per Client Instance + +Rejected design: + +- `deviceId -> records[]` +- A push replaces all records for that device. + +Reason rejected: + +- Multiple tabs/windows share `deviceId`. +- Stale windows can send incomplete state. +- Omitted records would become destructive. + +Revised design: + +- `(deviceId, clientInstanceId) -> open records[]` +- A push replaces only that client instance's open snapshot. +- Query aggregates all fresh client snapshots for a device. + +This gives us replacement semantics without pretending there is only one writer per device. + +### 2. Closed History Is a Tombstone Set + +Closed tabs cannot be controlled by omission. `localClosed` is currently memory-only in Redux. If a browser reloads and immediately sends a replacement snapshot without its earlier closed records, that must not erase server-side recently closed history. + +Revised rule: + +- Incoming closed records merge into `closedByTabKey`. +- A closed record remains until it loses last-write-wins resolution or exceeds retention. +- Omission from a later push does not delete a closed tombstone. +- If an incoming open record wins last-write-wins against an existing closed tombstone for the same `tabKey`, the server deletes that tombstone in the same committed mutation as the open snapshot replacement. + +This preserves the behavior users see today: recently closed tabs survive browser reloads and server restarts within retention. +It also prevents a reopened tab from leaving behind a stale closed card that could reappear after the new open snapshot expires. + +### 3. Default Closed Retention Is 30 Days + +The user-requested default is 30 days. + +Rules: + +- Default: 30 days. +- Allowed setting range: 1 to 30 days. +- Old browser preference values: + - missing -> 30 + - 1..30 -> preserve + - greater than 30 -> clamp to 30 +- Server stores closed tombstones up to the max retention window. Query uses all retained tombstones for conflict resolution, then filters closed winners to the requested local setting. + +The old 90-day and 365-day UI options go away. + +### 4. Open Liveness, Device Freshness, And Closed Retention Are Separate + +Remote open tabs should fall away when a device has not been seen recently. + +Rules: + +- Open snapshot freshness uses server receipt time, not record `updatedAt`. +- Default open snapshot TTL: 30 minutes. +- Default device display TTL: 7 days. +- Device display freshness is persisted in `devicesById`, not inferred from closed tombstones and not tied to open snapshot object refs. +- A running idle browser should stay fresh via a low-frequency forced heartbeat/snapshot. +- `updatedAt` remains the conflict-resolution timestamp for a tab record. + +This distinction matters: + +- `snapshotReceivedAt` answers "is this browser instance still around?" +- `devicesById[deviceId].lastSeenAt` answers "should this remote device still appear in device management?" +- `record.updatedAt` answers "which version of this tab record wins?" + +Open snapshots are meant to represent currently open tabs. If a browser window closes or crashes and cannot send a final retire message, its open snapshot should expire quickly. Device rows can remain visible longer for management and naming, but stale device metadata must not keep open tabs alive. + +### 5. Last-Write-Wins Must Still Resolve Open vs Closed + +Query must not simply append all fresh open records and all recent closed records. + +It must first combine candidate records by `tabKey` and select the newest record using event freshness, not heartbeat freshness: + +- higher `updatedAt` wins +- if `updatedAt` ties, higher `revision` wins +- if both tie, closed wins over open +- if still tied, use a deterministic source-key tie breaker + +Then it returns winners by status. + +This prevents a stale-but-fresh hidden window from resurrecting a tab that another window closed. The hidden window may keep sending its old open snapshot, but the newer closed tombstone wins for that `tabKey`. + +Important correction from the current code: client record `revision` is module-local and can reset after reload. It must not be the primary ordering signal. Heartbeats also must not rewrite `record.updatedAt` for unchanged open tabs; `snapshotReceivedAt` is the heartbeat/liveness time and must stay separate from the tab event time. + +### 6. No Silent Fallbacks + +If compact state is corrupt, migration fails, or the registry is unavailable, return a clear server/client error. Do not silently serve an empty snapshot. + +Atomic writes may keep a manual recovery copy, but the server should not automatically load an older backup as a hidden fallback without explicit approval. + +## Proposed Server Data Shape + +Add compact persistence under `~/.freshell/tabs-registry/`. + +Preferred active layout: + +```text +v1/ + manifest.json + objects/ + <sha256>.json + tmp/ +``` + +`manifest.json` is the only committed root. Object files are immutable JSON blobs referenced by the manifest: + +- one object per client open snapshot +- one object for closed tombstones +- one object for device metadata + +This keeps heartbeat writes small while making multi-file commits crash-safe. A mutation writes new object files first, then publishes one new manifest last. Startup loads only the objects referenced by the manifest and ignores orphaned objects from interrupted writes. + +In-memory shape: + +```ts +type CompactTabsRegistryStateV1 = { + version: 1 + savedAt: number + openSnapshotTtlMinutes: 30 + deviceDisplayTtlDays: 7 + maxClosedRetentionDays: 30 + openSnapshotsByClient: Record<string, ClientOpenSnapshot> + closedByTabKey: Record<string, RegistryTabRecord> + devicesById: Record<string, RegistryDeviceEntry> +} + +type ClientOpenSnapshot = { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + lastPushPayloadHash: string + snapshotReceivedAt: number + records: RegistryTabRecord[] +} + +type RegistryDeviceEntry = { + deviceId: string + deviceLabel: string + lastSeenAt: number +} + +type TabsRegistryManifestV1 = { + version: 1 + manifestRevision: number + committedAt: number + openSnapshots: Record<string, ObjectRef> + closedTombstones: ObjectRef + devices: ObjectRef + settings: { + openSnapshotTtlMinutes: 30 + deviceDisplayTtlDays: 7 + maxClosedRetentionDays: 30 + } +} + +type ObjectRef = { + path: string + sha256: string + bytes: number +} +``` + +Snapshot key: + +```ts +const clientSnapshotKey = `${deviceId}:${clientInstanceId}` +``` + +The manifest key is `clientSnapshotKey`. On-disk object filenames must be derived from validated content hashes, not raw user-controlled strings. + +Important constraints: + +- `records` in `ClientOpenSnapshot` must contain open records only. +- Incoming push payload may contain open and closed records. +- Server separates them: + - open records replace that client's open snapshot + - closed records merge into `closedByTabKey` +- Accepted pushes and retires update `devicesById[deviceId].lastSeenAt` from server receipt time. +- A heartbeat that does not change tab records updates only `snapshotReceivedAt` and the manifest/object state for that client snapshot, not the individual records' `updatedAt`. +- Incoming open records that beat existing closed tombstones remove those tombstones before the next manifest commit. + +Reason for per-client open objects: + +- Heartbeats should rewrite one small client snapshot, not a whole 5 MiB registry file. +- Closed tombstones change much less often and can live in their own bounded file. +- Device metadata is small and separate from tab history. +- Startup still loads bounded compact state, but the active write path is no longer a whole-registry rewrite for every idle heartbeat. + +## Protocol Changes + +This is a protocol-breaking change. + +- Bump `WS_PROTOCOL_VERSION` from 4 to 5. +- Updated browser bundles send protocol version 5 in `hello`. +- Old loaded browser bundles using version 4 receive the existing protocol mismatch error path with clear reload-required copy. +- Do not add a hidden compatibility adapter unless the user explicitly approves it. + +Current push: + +```ts +{ + type: 'tabs.sync.push', + deviceId, + deviceLabel, + records, +} +``` + +Revised push: + +```ts +{ + type: 'tabs.sync.push', + deviceId, + deviceLabel, + clientInstanceId, + snapshotRevision, + records, +} +``` + +Rules: + +- `clientInstanceId` is required. +- `snapshotRevision` is monotonically increasing per client instance, including across reloads that keep the same `sessionStorage` client id. +- Server rejects same-key snapshots with `snapshotRevision < current.snapshotRevision`. +- If `snapshotRevision === current.snapshotRevision`, the server treats it as an idempotent retry only when the canonical hash of the validated incoming push matches the already committed `lastPushPayloadHash`. Same revision with different content is a clear duplicate-revision error. +- `lastPushPayloadHash` excludes server receipt time and transport framing, but includes the validated device identity, client identity, revision, and open/closed records. +- Server acks only after validation and atomic persistence succeed. +- Ack should describe replacement semantics, not claim `updated: records.length`. + +Revised ack: + +```ts +{ + type: 'tabs.sync.ack', + accepted: true, + openRecords: number, + closedRecords: number, +} +``` + +Best-effort client retire: + +```ts +{ + type: 'tabs.sync.client.retire', + deviceId, + clientInstanceId, + snapshotRevision, +} +``` + +Rules: + +- Sent on explicit disconnect where possible and from `pagehide`/unload using a keepalive request or beacon if WebSocket delivery is not reliable. +- Server deletes only that `(deviceId, clientInstanceId)` open snapshot. +- Server ignores stale retire messages with `snapshotRevision < current.snapshotRevision`. +- Retire is an optimization, not the correctness mechanism; the 30-minute open snapshot TTL remains required for crashes and missed unloads. + +Current query uses `rangeDays`. Revised query should use the semantic name: + +```ts +{ + type: 'tabs.sync.query', + requestId, + deviceId, + clientInstanceId, + closedTabRetentionDays, +} +``` + +Rules: + +- `clientInstanceId` is required so the server can distinguish the current browser window from other windows on the same device. +- `closedTabRetentionDays` is required from updated clients. +- Schema clamps/rejects outside 1..30 at the WebSocket boundary. +- Prefer rejection with a clear error for invalid client payloads. + +Revised snapshot data: + +```ts +{ + localOpen: RegistryTabRecord[] + sameDeviceOpen: RegistryTabRecord[] + remoteOpen: RegistryTabRecord[] + closed: RegistryTabRecord[] + devices: RegistryDeviceEntry[] +} +``` + +Rules: + +- `localOpen` contains only records owned by the querying `(deviceId, clientInstanceId)`. +- `sameDeviceOpen` contains records from other browser windows with the same `deviceId`. +- `remoteOpen` contains records from other devices. +- `devices` contains recent device metadata from `listDevices()`, filtered by the 7-day device display TTL and sorted by `lastSeenAt` descending. +- Open records should include source metadata (`deviceId`, `deviceLabel`, `clientInstanceId`) so the UI cannot accidentally treat same-device-other-window records as jumpable local tabs. +- The Tabs view may continue deriving currently open local tabs from Redux for jump actions, but server-returned same-device records must be treated like copy/pullable records, not like current-window jump targets. +- The Settings Devices view reads device rows from `tabs.sync.snapshot.data.devices` and combines that with the current local device identity if the current device has not yet received a server ack. + +## Store API + +Replace the current `upsert(record)` API with batch operations that match ownership. + +```ts +type ReplaceClientSnapshotInput = { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + records: RegistryTabRecord[] +} + +class TabsRegistryStore { + static async open(rootDir: string, options?: TabsRegistryStoreOptions): Promise<TabsRegistryStore> + + async replaceClientSnapshot(input: ReplaceClientSnapshotInput): Promise<{ + accepted: boolean + openRecords: number + closedRecords: number + }> + + async retireClientSnapshot(input: { + deviceId: string + clientInstanceId: string + snapshotRevision: number + }): Promise<{ accepted: boolean }> + + async query(input: { + deviceId: string + clientInstanceId: string + closedTabRetentionDays: number + }): Promise<TabsRegistryQueryResult> + + listDevices(): Array<{ + deviceId: string + deviceLabel: string + lastSeenAt: number + }> + + count(): number +} +``` + +`open()` must be async because migration is streaming and must complete before the store is usable. +`listDevices()` reads `devicesById`, prunes entries older than 7 days through queued maintenance, and never derives device rows from closed tombstones. + +## Persistence Rules + +Active persistence: + +- Write compact JSON object files plus one manifest commit pointer only. +- No active append-only JSONL. +- Persist open snapshots as per-client immutable objects under `v1/objects/`. +- Persist closed tombstones and device metadata as separate immutable objects under `v1/objects/`. +- Persist registry version/settings and object references in `v1/manifest.json`. +- Write all mutations through a serialized write queue. +- Atomic publish is manifest-last: + 1. write changed object blobs to `v1/tmp/` + 2. validate size/hash while writing + 3. fsync object files and containing directories where supported + 4. rename objects into `v1/objects/` + 5. write and fsync `manifest.json.tmp` + 6. atomically rename `manifest.json.tmp` to `manifest.json` + 7. fsync `v1/` +- Startup loads exactly the object refs named by the latest valid manifest. It ignores orphaned objects and temp files. +- Garbage collection of unreferenced objects is a separate maintenance step after a successful commit. +- Use copy-on-write state mutation: + 1. clone or derive the next bounded in-memory state + 2. validate caps and schemas against the next state + 3. write changed objects and publish the manifest + 4. swap the live in-memory state only after the manifest commit succeeds +- Validate compact state before accepting it into memory on startup. + +Caps: + +- Max records per push: 500. +- Max open records per client snapshot: 500. +- Max closed records accepted per push: 500. +- Max panes per tab record: 20. +- Max serialized push bytes: 1 MiB. +- Max serialized client snapshot object bytes: 512 KiB. +- Max serialized closed tombstone object bytes: 2 MiB. +- Max serialized device metadata object bytes: 256 KiB. +- Max compact state bytes after retention maintenance: 5 MiB. +- Max client snapshot object refs: 200. +- Max closed tombstones after retention pruning: 2,000 newest. +- Max retained bytes during migration: 5 MiB, enforced as records are retained, not only after final compaction. + +If caps are exceeded: + +- Reject push. +- Send clear WS error. +- Do not truncate open snapshots silently. + +Closed tombstones may be pruned by age and by the max-tombstone cap, keeping newest records first. That is not a fallback; it is an explicit retention policy. + +Read/query behavior: + +- `query()` must be pure. It can compute filtered results from the current in-memory state, but it must not mutate state or write files. +- Retention cleanup runs as a queued maintenance write, either after successful pushes/retires or on a low-frequency timer. +- If maintenance cleanup fails, queries should still use snapshot isolation over the last successfully persisted in-memory state and expose/log the maintenance error clearly. + +Failure behavior: + +- A failed write before manifest publish must not alter live query results or startup-visible disk state. +- Once manifest publish succeeds, the mutation is committed even if the process crashes before ack; retry handling should be idempotent for the already-committed snapshot revision from the same client. +- A failed write must return a clear error to the WebSocket caller. +- Tests must prove that injected object-write, object-rename, manifest-write, and manifest-rename failures leave memory and startup-visible disk on the previous committed state. +- Tests must simulate crash/restart between object writes and manifest publish, and after manifest publish before ack. + +## Query Algorithm + +Inputs: + +- `deviceId` +- `clientInstanceId` +- `closedTabRetentionDays` +- `now` + +Steps: + +1. Compute fresh client snapshots where `snapshotReceivedAt >= now - 30 minutes`. + - Do not mutate or persist from `query()`. + - Expired snapshots are excluded from this response and removed later by queued maintenance. +2. Build conflict-resolution candidates: + - all open records from fresh client snapshots + - all closed tombstones retained by the server's max closed retention window, even if they are older than the caller's requested range +3. Resolve candidates by `tabKey` using the event-time LWW helper: + - higher `updatedAt` + - then higher `revision` + - then closed over open + - then deterministic source-key tie breaker +4. Apply the caller's requested `closedTabRetentionDays` only to closed winners. + - Example: if a tab was closed 10 days ago and the user selects 7 days, that closed winner is omitted from `closed`, but an older open snapshot for the same `tabKey` must still stay suppressed. + - This prevents shorter display retention from becoming a resurrection path. +5. Split remaining winners: + - open + same `deviceId` and same `clientInstanceId` -> `localOpen` + - open + same `deviceId` and different `clientInstanceId` -> `sameDeviceOpen` + - open + different `deviceId` -> `remoteOpen` + - closed within requested retention -> `closed` +6. Sort: + - open by `updatedAt` descending + - closed by `closedAt ?? updatedAt` descending + +Maintenance write, not query: + +1. Remove open snapshots older than the open snapshot TTL. +2. Remove closed tombstones older than max closed retention. +3. Remove device metadata older than the device display TTL. +4. Enforce max snapshot-object-ref, tombstone, device, and byte caps. +5. Persist cleanup through the serialized copy-on-write queue. + +This preserves the current mental model while avoiding historical storage. + +## Legacy Migration + +Legacy file: + +```text +tabs-registry.jsonl +``` + +Migration must be one-time and streaming. + +Rules: + +1. If the compact `v1/manifest.json` exists, do not read legacy JSONL. +2. If compact state does not exist and legacy JSONL exists, stream it line by line. +3. Parse each valid record with the existing schema. +4. Enforce migration safety caps while streaming: + - Max legacy line bytes: 256 KiB. + - Max valid unique tab keys retained during migration: 10,000. + - Max migrated open snapshots/devices: 200. + - Max serialized retained record bytes: 5 MiB, enforced as records are retained and replaced. + - Max migrated compact state after retention maintenance: 5 MiB. + - If a cap is exceeded, fail startup with a clear recovery error rather than continuing toward memory pressure. + - Large valid pane payloads count toward the retained-byte budget before they can accumulate in memory. +5. Compute latest record per `tabKey` first using the same event-time LWW helper as query. +6. Only after latest-per-tab resolution: + - closed latest records within 30 days become `closedByTabKey` + - open latest records are grouped into synthetic migrated snapshots by `deviceId` +7. Synthetic migrated snapshots use: + - `clientInstanceId: 'legacy-migration'` + - `snapshotRevision: 1` + - `snapshotReceivedAt: migrationStartedAt` + - normal open snapshot TTL expiration +8. `devicesById` entries are created from migrated latest records with `lastSeenAt: migrationStartedAt`, then expire under the normal 7-day device display TTL unless a real client reconnects. +9. The migration-time receipt gives currently loaded clients a short grace period to reconnect and publish real per-window snapshots. It does not keep legacy opens alive for 7 days. +10. Write compact object files and publish the manifest atomically. +11. Rename legacy JSONL to an archived name only after compact manifest publish succeeds. + +Archive name example: + +```text +tabs-registry.jsonl.migrated-20260507-143012 +``` + +Critical ordering: + +- Do not prune closed records before latest-per-tab resolution. +- Otherwise an old closed tombstone could be discarded and an older open record could be resurrected. +- Do not use legacy record `updatedAt` as open snapshot liveness evidence. It is tab-event time, not proof that the browser is still around. + +Startup rule: + +- Store opening must be awaited before `WsHandler` is created. +- If migration fails, startup should expose a clear registry error. Do not serve empty tab snapshots. + +## Client Changes + +### Client Instance Identity + +Add a per-window `clientInstanceId`. + +Rules: + +- Generated once per browser window. +- Stored in `sessionStorage`, not `localStorage`. +- Reused across reloads of the same browser window. +- Included in every `tabs.sync.push`. +- New browser window gets a different `clientInstanceId`. +- Browser reload keeps the same `clientInstanceId` and replaces the same server snapshot. +- If a duplicated tab copies `sessionStorage`, use `BroadcastChannel` or an equivalent local lease to detect an already-active identical `clientInstanceId` and mint a fresh one for the duplicate window. + +Candidate location: + +- `src/store/tabRegistrySync.ts` local module state, or +- `tabRegistrySlice` state if UI/debug display needs it. + +Prefer module state unless tests become cleaner with Redux state. + +### Snapshot Revision + +Keep a monotonic `snapshotRevision` per client instance. + +Rules: + +- Store the last sent revision beside `clientInstanceId` in `sessionStorage`. +- Increment when sending a push. +- Continue from the stored value after reload. +- Do not use tab record revision for snapshot ordering. +- Server rejects stale snapshot revisions for the same `(deviceId, clientInstanceId)` and handles exact duplicate retries idempotently only when the payload matches. +- Retire messages also carry a revision so an old unload cannot delete a newer reloaded snapshot. + +### Push Behavior + +Current behavior already builds records from: + +- current open tabs +- `tabRegistry.localClosed` + +Revised behavior: + +- Keep sending open records for current tabs. +- Send closed records from local memory while they exist and are within retention. +- Do not rely on omission to delete server-side closed records. +- Add a forced heartbeat/snapshot interval so idle active browsers refresh `snapshotReceivedAt`. +- Real tab lifecycle changes still update the affected record's `updatedAt` before the next push. +- Do not update per-record `updatedAt` for unchanged open tabs during heartbeat. +- Send a best-effort `tabs.sync.client.retire` when the app/window is closing, while keeping TTL as the correctness backstop. + +Suggested intervals: + +- Existing push interval remains 5 seconds for changed lifecycle state. +- Add forced heartbeat snapshot every 5 minutes while WebSocket is ready. +- Heartbeat can send the same compact payload with a new `snapshotRevision`. + +### Closed Retention State + +Rename client state: + +- from `searchRangeDays` +- to `closedTabRetentionDays` + +Default: + +- 30 + +Browser preferences: + +- Load old `tabs.searchRangeDays` for migration. +- Store new `tabs.closedTabRetentionDays`. +- Write only the new key after any preferences save. +- Clamp to 1..30. + +Cross-tab sync: + +- Update browser preference hydration to carry `closedTabRetentionDays`. +- Preserve pending local writes as current code does for `searchRangeDays`. + +Tabs View: + +- Replace `Last 30 days / Last 90 days / Last year` with bounded options: + - `Last 1 day` + - `Last 7 days` + - `Last 14 days` + - `Last 30 days` +- Default selected: `Last 30 days`. +- Always send the chosen retention to the server query. + +Settings: + +- Add a setting row for closed tab history if we want it outside the Tabs view. +- If only the Tabs view selector owns it, make the selector label clear enough. + +Device management: + +- Settings "Devices" should represent own device plus recent remote devices from `tabs.sync.snapshot.data.devices`. +- `devicesById` is updated only from server receipt of accepted client messages, not from historical closed-record timestamps. +- The server sends `devices` in every `tabs.sync.snapshot` response; no separate device endpoint is needed for this change. +- Do not keep a remote device row alive solely because it has a closed tombstone retained for 30 days. +- Keep a remote device row for up to 7 days after `lastSeenAt`, even after its open snapshots expire at 30 minutes. +- Closed tab cards can still show the record's device label. + +This satisfies both: + +- remote open snapshots fall away after the freshness TTL +- remote device rows fall away after the device display TTL +- closed tab history can remain visible for 30 days + +## ServerInstanceId Rules + +The current server overwrites pushed records with the connected server's `serverInstanceId`. + +Keep that for live open snapshots from the current connection. + +Be careful with closed records: + +- If a closed record originated on the current server, preserving/overwriting to current server is fine. +- `localClosed` must track the `ready.serverInstanceId` it belongs to. +- If `ready.serverInstanceId` changes during the same app session, clear `localClosed` before the next push or namespace the in-memory closed map by `serverInstanceId` and send only the current namespace. +- This prevents closed records from server A being re-authored as server B. +- This plan avoids requiring client-side persisted closed history, so the immediate implementation can keep closed history memory-only and clear it on server switch. + +Legacy migration should preserve the `serverInstanceId` already stored in each legacy record. + +Reason: + +- TabsView uses `serverInstanceId` to decide whether live terminal handles can be reused. +- Migration is restoring historical records, not re-authoring them through a live WebSocket connection. + +## Implementation Phases + +### Phase 1: Contract Tests For New Semantics + +Add failing tests before implementation. + +Server store unit tests: + +- Open snapshot replacement is scoped to `(deviceId, clientInstanceId)`. +- Two client instances on the same device do not erase each other. +- Query splits current-client `localOpen` from same-device-other-window `sameDeviceOpen`. +- Reloaded same-window client reuses `clientInstanceId` and replaces the prior snapshot. +- Stale snapshot revision for the same client is rejected. +- Retry of an already committed same-client snapshot revision is idempotent after a lost ack. +- Stale retire does not delete a newer snapshot. +- Closed tombstone survives later open snapshot omission. +- Newer closed tombstone suppresses stale open record. +- Newer open record suppresses older closed tombstone. +- Newer open record deletes the older closed tombstone on write, so the old closed card does not return after open TTL expiry or restart. +- Closed tombstone older than requested retention still participates in LWW and can suppress an older open. +- `updatedAt` beats reset-prone `revision`; a reload-then-close record with lower revision can beat an older open record. +- Deterministic LWW ties choose closed over open and produce stable results. +- Query uses server receipt time for snapshot freshness. +- Query is pure and does not prune/write. +- Open snapshot TTL is 30 minutes; device display TTL is 7 days. +- Device metadata survives restart after open snapshot TTL but before 7-day device TTL. +- Device metadata is not created or kept alive from closed tombstones alone. +- Closed retention defaults to 30 and clamps/rejects outside 1..30. +- Stale snapshots are excluded from query and pruned by queued maintenance. +- Oversized pushes are rejected. +- Oversized pane snapshots are rejected by byte budget, even when record counts are under caps. +- Duplicate tab keys in one push are resolved or rejected explicitly. + +Integration/persistence tests: + +- Manifest-referenced per-client snapshot objects, closed tombstones, and devices rehydrate without JSONL. +- Orphaned objects/temp files from interrupted writes are ignored on startup. +- Maintenance garbage-collects unreferenced objects after successful commits and preserves every object referenced by the current manifest. +- Crash/restart before manifest publish loads the previous committed state. +- Crash/restart after manifest publish loads the new committed state. +- Legacy JSONL migration computes latest per tab before pruning. +- Old closed tombstone does not resurrect older open record. +- Legacy migration uses migration-time liveness for open snapshots, not legacy `updatedAt`. +- Migration caps fail with a clear recovery error before unbounded memory growth. +- Migration retained-byte budget fails on large valid pane payloads before memory climbs. +- Legacy file is archived only after compact write succeeds. +- Startup awaits migration before WS can query. +- Corrupt compact file produces a clear error, not empty data. +- Injected object-write, object-rename, manifest-write, and manifest-rename failures leave memory and startup-visible disk at the previous committed state. +- Concurrent query during queued push sees either old or new committed state, never partial state. + +WebSocket tests: + +- `WS_PROTOCOL_VERSION` is bumped from 4 to 5. +- Version 4 clients receive a clear reload-required protocol mismatch. +- `tabs.sync.push` requires `clientInstanceId` and `snapshotRevision`. +- Ack reports accepted/open/closed counts. +- `tabs.sync.client.retire` removes only that client snapshot and rejects/ignores stale revisions. +- Query requires/uses `clientInstanceId` and `closedTabRetentionDays`. +- Snapshot data includes `sameDeviceOpen`. +- Snapshot data includes `devices` from `listDevices()`. +- `closedTabRetentionDays > 30` is rejected. +- Missing registry returns clear error for query, not empty snapshot. + +Client tests: + +- Sync includes `sessionStorage` `clientInstanceId` and increasing `snapshotRevision`. +- Tabs sync query includes the same `clientInstanceId` used by push. +- Reload preserves client id/revision; new window gets a distinct id. +- Duplicated-tab `sessionStorage` collision is detected and rotated. +- Forced heartbeat sends even when record fingerprint is unchanged. +- Real tab lifecycle changes update the changed open record's `updatedAt`. +- Heartbeat does not mutate tab record `updatedAt`. +- Best-effort retire is sent on close/pagehide where the environment supports it. +- Closed records older than retention are not sent. +- `localClosed` clears or namespaces when `ready.serverInstanceId` changes. +- Browser preference migration clamps old `searchRangeDays`. +- Old/new preference mixed cross-tab sync converges on `closedTabRetentionDays`. +- Cross-tab preference sync preserves pending local `closedTabRetentionDays`. +- Tabs view no longer offers 90/365. +- Tabs view does not offer jump actions for `sameDeviceOpen` records from other browser windows. +- Settings devices are not kept alive solely by closed tombstones. +- Settings devices read `tabs.sync.snapshot.data.devices` and are kept by `devicesById` until the 7-day display TTL. +- `docs/index.html` mock is updated if it shows the old 90/365 retention options. + +### Phase 2: Compact Store Types And Helpers + +Modify: + +- `server/tabs-registry/types.ts` +- `server/tabs-registry/store.ts` +- `server/tabs-registry/device-store.ts` + +Add: + +- compact state schema +- push input schema +- event-time LWW helper shared by migration/query +- pure filter helpers and queued maintenance prune helpers +- size/cap validation +- copy-on-write manifest commit helper +- content-hash object writer +- safe client snapshot manifest key validation helper +- explicit tombstone retirement helper for incoming open records that win LWW +- device metadata helper backed by `devicesById` + +Keep imports NodeNext-compatible with `.js` extensions. + +### Phase 3: Async Open And Migration + +Modify: + +- `server/tabs-registry/store.ts` +- `server/index.ts` + +Replace synchronous constructor hydration with: + +```ts +const tabsRegistryStore = await createTabsRegistryStore() +``` + +or: + +```ts +const tabsRegistryStore = await TabsRegistryStore.open(...) +``` + +The factory must: + +1. ensure directory exists +2. load compact `v1/` state if present +3. otherwise migrate legacy JSONL if present +4. otherwise initialize empty compact state + +No WebSocket handler should receive the store before this completes. + +### Phase 4: WebSocket Protocol Wiring + +Modify: + +- `server/ws-handler.ts` +- `src/lib/ws-client.ts` +- `shared/ws-protocol.ts` +- related tests + +Replace looped `upsert` calls with one store call: + +```ts +await tabsRegistryStore.replaceClientSnapshot(...) +``` + +Add protocol handling for: + +```ts +await tabsRegistryStore.retireClientSnapshot(...) +``` + +Include `clientInstanceId` in query messages and return `sameDeviceOpen` plus `devices` in snapshots. +Increment `WS_PROTOCOL_VERSION` to 5 and rely on the existing protocol mismatch path for old loaded clients, with clearer reload-required copy if needed. +Do not send empty snapshots when the registry is unavailable. Send a clear error. + +### Phase 5: Client Sync And Preferences + +Modify: + +- `src/store/tabRegistrySync.ts` +- `src/store/tabRegistrySlice.ts` +- `src/lib/browser-preferences.ts` +- `src/store/browserPreferencesPersistence.ts` +- `src/store/crossTabSync.ts` +- `src/store/selectors/tabsRegistrySelectors.ts` +- `src/store/types.ts` +- tests under `test/unit/client` + +Add: + +- `sessionStorage` `clientInstanceId` +- `sessionStorage` `snapshotRevision` +- duplicated-tab client id collision handling +- query messages carrying `clientInstanceId` +- heartbeat push +- best-effort retire +- retention rename/migration +- retention-aware closed pruning +- `localClosed` server-instance guard + +Keep `localClosed` memory-only unless implementation proves a client-side persistence gap remains. Server tombstones should handle reload survival. + +### Phase 6: Tabs View And Device UI + +Modify: + +- `src/components/TabsView.tsx` +- `src/components/settings/SafetySettings.tsx` +- `src/lib/known-devices.ts` +- `docs/index.html` if it contains stale Tabs mock retention options +- relevant unit/e2e tests + +Tabs View: + +- remove 90/365 options +- default to 30 +- send `closedTabRetentionDays` +- show or merge `sameDeviceOpen` separately from current-window local records +- do not render same-device-other-window records with current-window jump actions + +Devices: + +- base device rows on `devicesById`/server device metadata +- hydrate device rows from `tabs.sync.snapshot.data.devices` +- keep aliases/dismissal behavior +- do not use closed-only records to keep stale devices alive + +### Phase 7: Verification + +Focused commands: + +```bash +npm run test:vitest -- test/unit/server/tabs-registry/store.test.ts --run +npm run test:vitest -- --config vitest.server.config.ts test/integration/server/tabs-registry-store.persistence.test.ts --run +npm run test:vitest -- --config vitest.server.config.ts test/server/ws-tabs-registry.test.ts --run +npm run test:vitest -- test/unit/client/store/tabRegistrySync.test.ts test/unit/client/lib/browser-preferences.test.ts test/unit/client/store/browserPreferencesPersistence.test.ts test/unit/client/store/crossTabSync.test.ts --run +npm run test:vitest -- test/unit/client/components/TabsView.test.tsx test/unit/client/components/SettingsView.behavior.test.tsx --run +``` + +Then coordinated broad checks: + +```bash +FRESHELL_TEST_SUMMARY="tabs registry compact state" npm run test:status +FRESHELL_TEST_SUMMARY="tabs registry compact state" npm run check +``` + +Manual perf verification: + +1. Build from the worktree. +2. Copy a large legacy `tabs-registry.jsonl` fixture into a temp Freshell home. +3. Start production server on a unique port with that temp home. +4. Confirm startup does not read/split the full file into heap. +5. Confirm compact files are small. +6. Confirm legacy JSONL is archived. +7. Confirm remote tabs and recently closed tabs still appear correctly. +8. Benchmark heartbeat write latency near the configured caps and confirm it writes only the relevant client snapshot object, small device metadata object if needed, and manifest. +9. Confirm unreferenced-object garbage collection bounds disk usage after repeated heartbeat commits and never removes manifest-referenced objects. + +Expected result: + +- No active `tabs-registry.jsonl` growth. +- Compact state well under a few MiB in normal use. +- Startup heap does not spike around 1 GiB from tabs registry hydrate. + +## Acceptance Criteria + +- Server no longer appends to active `tabs-registry.jsonl`. +- Server startup does not `readFileSync` and `split` a large tabs-registry JSONL file. +- Legacy migration streams line by line. +- Compact state is versioned, schema-validated, and committed through a manifest pointer. +- Startup ignores orphaned object/temp files and loads only manifest-referenced objects. +- Open replacement is scoped to `(deviceId, clientInstanceId)`. +- Same-device multiple browser windows cannot erase each other's open tabs. +- Same-device other-window tabs are distinguishable from current-window local tabs. +- Same-window reloads reuse the same `sessionStorage` client id and replace the prior snapshot. +- `tabs.sync.snapshot.data.devices` is the client transport for recent device metadata. +- Closed history survives browser reload and server restart for up to 30 days. +- Retained closed tombstones participate in conflict resolution before requested-range filtering. +- Newer reopened open records delete older closed tombstones so stale closed cards do not return after open TTL expiry. +- Tab conflict ordering lets newer `updatedAt` beat stale higher `revision`. +- Stale hidden-window open records cannot resurrect newer closed tabs. +- Remote open snapshots fall away after 30 minutes without server receipt. +- Remote device rows are backed by `devicesById` and fall away after 7 days without server receipt. +- Idle active browser instances remain fresh through heartbeat. +- Real tab lifecycle changes advance the affected record's `updatedAt`. +- Heartbeat updates snapshot liveness without changing per-record `updatedAt`. +- Best-effort retire removes only the calling client snapshot and stale retires cannot delete newer snapshots. +- Retention default is 30 days. +- Retention setting is clamped/rejected to 1..30. +- 90-day and 365-day closed history options are gone. +- Query is pure; pruning happens through queued maintenance writes. +- Failed atomic writes do not change live query results. +- Crash/restart before manifest publish loads previous state; crash/restart after manifest publish loads new state. +- Unreferenced-object garbage collection keeps disk bounded without deleting manifest-referenced objects. +- Legacy migration has explicit memory/size caps and uses migration-time liveness for synthetic open snapshots. +- Query failures are explicit; no empty-snapshot fallback. +- Oversized/malformed pushes are rejected clearly. +- Large pane snapshots cannot bypass byte caps. +- WebSocket protocol version is bumped and old loaded clients get a clear reload-required error. +- Existing Tabs behavior still works: + - jump to local tab + - pull remote tab copy + - reopen closed tab + - preserve pane snapshots + - preserve serverInstanceId behavior for live handles + - preserve device aliases and dismissal +- Focused tests pass. +- Coordinated `npm run check` passes before merge. + +## Risks And Mitigations + +Risk: client instance snapshots accumulate after browser crashes. + +Mitigation: + +- 30-minute open snapshot TTL. +- Query excludes stale snapshots without mutating state. +- Queued maintenance prunes stale snapshot object refs and later garbage-collects unreferenced objects. + +Risk: heartbeat creates needless writes. + +Mitigation: + +- Heartbeat interval is low frequency. +- Heartbeat writes one small per-client open snapshot object, the small device metadata object if `lastSeenAt` changes, and a manifest. +- Heartbeat does not rewrite the closed tombstone object unless closed records changed or a reopened open record removes an old tombstone. +- Writes remain bounded and do not append history. + +Risk: changing protocol breaks stale browser bundles. + +Mitigation: + +- Bump `WS_PROTOCOL_VERSION` to 5. +- Let version 4 clients fail the handshake through the existing protocol mismatch path with reload-required copy. +- Reject invalid/missing `clientInstanceId` on version 5 messages with a clear error. +- Do not maintain a long-term compatibility fallback unless explicitly approved. + +Risk: compact state still grows from bad clients. + +Mitigation: + +- hard caps on push size, records, panes, snapshot object refs, tombstones, and compact state size +- per-object byte caps plus a global live compact-state byte cap before every manifest commit +- migration retained-byte budget enforced while streaming +- clear errors on rejection + +Risk: migration shows stale historical open tabs or drops useful current open tabs. + +Mitigation: + +- migrated open snapshots get a short migration-time grace period +- current active browsers push immediately after reconnect/startup and replace synthetic snapshots +- stale historical opens fall away after the open snapshot TTL +- legacy `updatedAt` is never used as browser liveness evidence + +## Implementation Handoff + +Implement this plan in the dev-based worktree: + +```text +/home/user/code/freshell/.worktrees/tabs-registry-device-snapshots-dev +``` + +Use Red-Green-Refactor. Keep changes committed in the worktree after each coherent phase. diff --git a/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md b/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md new file mode 100644 index 000000000..05428a2f4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md @@ -0,0 +1,497 @@ +# Codex Turn-Completion Durability Implementation Plan + +> **For Claude:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Codex terminal restore identity mandatory, observable, and promoted only by deterministic evidence. A fresh Codex pane must not accept user input until Freshell has persisted the provider-reported candidate thread id and rollout path, must not treat that candidate as durable, and must promote to `sessionRef` only after the exact rollout file proves the same Codex root TUI `ThreadId`. + +**Architecture:** Add a Freshell-owned websocket proxy between the visible Codex TUI and the Codex app-server sidecar. The proxy observes `thread/start` responses and `thread/started` notifications for candidate capture, observes `turn/completed` for the mandatory proof-check boundary, and forwards traffic normally. Terminal input is gated only until the candidate is atomically written to the Freshell server-side durability store. Durable promotion is an event-driven one-shot proof read of the exact rollout path, not a polling loop. + +**Tech Stack:** Node.js/TypeScript ESM, `ws`, `node-pty`, Express WebSocket protocol, React 18, Redux Toolkit, Zod, Vitest, Testing Library, superwstest, Freshell orchestration. + +--- + +## Research Contract + +- Codex durable restore identity is not a title, cwd, launch time, shell snapshot, or the bare bootstrap id. It is the root TUI `ThreadId` after the exact provider-reported `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` begins with matching `session_meta` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:9`, `:15-19`, `:492-504`). +- Fresh `codex --remote <ws>` creates a thread before user work. Freshell must capture that candidate before letting user input through, persist it as candidate-only state, and promote only after rollout proof (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:16`, `:99-127`, `:561-570`). +- Pre-creating a thread through the app-server and launching the TUI with `codex resume <threadId>` before the rollout exists fails with `no rollout found for thread id`; this implementation must remove that launch pattern for fresh Codex panes (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:19`, `:369-412`, `:474-480`, `:550`). +- The Codex source proves the TUI receives `thread/start` response before the `thread/started` notification, both with the candidate id/path, and it does not read terminal input until after the thread is started. Freshell still needs a PTY-side input gate because terminal bytes can queue outside Codex before Freshell persists the candidate (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:440-446`, `:551-553`). +- `turn/completed` is the required proof-check boundary, not proof. On that event for the candidate thread, Freshell must run exactly one direct proof read of the stored rollout path. It promotes only if the file is regular, readable JSONL whose first record is `type == "session_meta"` and `payload.id == candidateThreadId` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:17`, `:124-134`, `:456`, `:466-468`, `:520-526`, `:555`). +- `fs/watch` is only a wake-up source. A missed filesystem event was observed in the probe, so it cannot be the only promotion path. It also cannot replace the direct proof read (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:325-331`, `:482-490`, `:554`, `:586-588`). +- After `turn/completed`, proof failure is not an acceptable green, grey, or silently live-only steady state. It is `durability_unproven_after_completion` until a deterministic one-shot repair trigger succeeds or the pane becomes non-restorable (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:506-518`, `:520-538`, `:584-590`). +- Reopen of captured-but-unproven Codex state proof-reads first. If proof succeeds, promote and resume. If proof fails and a live terminal is attachable, attach live while keeping the degraded state visible. If no live terminal is attachable, fresh-create a Codex pane and show that the old captured Codex state could not be proven restorable. Do not try `codex resume <candidateThreadId>` before proof (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`). + +## Current Gap In This Worktree + +- `server/coding-cli/codex-app-server/launch-planner.ts` currently calls `runtime.startThread()` for fresh Codex launches and returns that id as the launch `sessionId`; `server/ws-handler.ts` then passes it as `resumeSessionId`, so `buildSpawnSpec()` launches the visible TUI with `codex --remote <ws> resume <threadId>`. This is exactly the pre-durable resume pattern the research rejects. +- `server/coding-cli/codex-app-server/protocol.ts`, `client.ts`, and `runtime.ts` handle `thread/started`, lifecycle loss, and `fs/changed`, but do not expose `turn/completed`. +- `server/terminal-registry.ts` writes PTY input immediately once the terminal exists. It has no state that can block input until candidate persistence is complete. +- `src/components/TerminalView.tsx` persists canonical identity only after `terminal.session.associated`; it has no candidate-only Codex durability state and no acknowledgement path back to the server. +- The sidebar and persisted tab state can represent `sessionRef` or legacy `resumeSessionId`, but not a non-canonical Codex candidate. This is why an unpromoted live Codex pane can appear as a generic grey terminal and then split into a second entry when canonical metadata appears later. +- The research document also mentions `durable-rollout-tracker.ts` and a pre-durable stability timer that promotes to `running_live_only` in `/home/user/code/freshell/.worktrees/dev` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:576-582`). That timer-based promotion is not present in this `origin/main`-based implementation worktree. A `running_live_only` type string still exists in recovery policy code, so this plan avoids removing the enum value unless implementation proves it is dead. + +## State Model To Implement + +- `identity_pending`: fresh Codex TUI has been spawned through the proxy, but no candidate has been durably saved by Freshell. PTY output, resize, and narrow terminal-control replies required for TUI startup pass through. User-originating input is dropped, not buffered or replayed, and the server emits `terminal.input.blocked` for observability. +- `captured_pre_turn`: Freshell has atomically persisted `{ provider: "codex", candidateThreadId, rolloutPath, source, capturedAt }` in the server-side durability store. Input is allowed. This is not restorable/durable. Client localStorage acknowledgement may arrive later and is idempotent. +- `turn_in_progress_unproven`: the proxy observed `turn/start` or equivalent user-turn activity for the candidate. Live use continues. This is not restorable/durable. +- `proof_checking`: `turn/completed` or a deterministic repair trigger fired and one exact proof read is running. +- `durable`: the proof succeeded. Freshell sends the existing `terminal.session.associated` message with `sessionRef.provider == "codex"` and `sessionRef.sessionId == candidateThreadId`; normal resume uses that id. +- `durable_resuming`: a terminal launched from an existing canonical Codex `sessionRef`. It starts from already-proven durable identity and does not return to candidate capture. Normal launch trusts the saved canonical `sessionRef`; if durable proof metadata with `rolloutPath` is also available, repair/list/open paths may proof-read it before resume. If no proof metadata exists, Freshell must not invent one from cwd/time/title. +- `durability_unproven_after_completion`: proof failed after completion. Live terminal access remains possible if the PTY is alive, but sidebar/pane state must be degraded, not green/normal. +- `non_restorable`: no durable proof exists and no live terminal is attachable. Reopening fresh-creates Codex and keeps a local restore-error explanation. + +## Implementation Tasks + +### 1. Add Codex Durability Types And Proof Reader + +- [ ] Create `shared/codex-durability.ts`. + - [ ] Export `CodexDurabilityStateName` with the exact state names above. + - [ ] Export `CodexCandidateIdentity` with `provider: "codex"`, `candidateThreadId`, `rolloutPath`, `source`, `capturedAt`, and optional `cliVersion`. + - [ ] Export `CodexDurabilityRef` with `schemaVersion: 1`, `state`, `candidate`, optional `turnCompletedAt`, optional `lastProofFailure`, optional `durableThreadId`, and optional `nonRestorableReason`. + - [ ] Add Zod schemas so persisted client state and websocket payloads are validated instead of using ad hoc objects. + - [ ] Keep names explicit: use `candidateThreadId`, `rolloutProofId`, and `durableThreadId`, matching the research terminology (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`). +- [ ] Create `server/coding-cli/codex-app-server/durability-proof.ts`. + - [ ] Export `proofCodexRollout({ rolloutPath, candidateThreadId, fsImpl? })`. + - [ ] Require `rolloutPath` to be absolute and non-empty. + - [ ] `stat()` the exact path and require a regular file. + - [ ] Read only enough data to parse the first JSONL record; do not scan globs or nearby files. + - [ ] Require first record JSON to have `type === "session_meta"` and `payload.id === candidateThreadId`. + - [ ] Return a typed success/failure result with a machine-readable reason: `missing`, `not_regular_file`, `empty`, `malformed_json`, `wrong_record_type`, `missing_payload_id`, `mismatched_thread_id`, `read_error`. + - [ ] Do not check cwd, date directories, shell snapshots, or filename proximity. The proof is the exact path plus first-record identity (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:456`, `:520-526`). +- [ ] Create `server/coding-cli/codex-app-server/durability-store.ts`. + - [ ] Atomically persist candidate and proof-state records under a Freshell-owned directory, defaulting to `~/.freshell/codex-durability/`. + - [ ] Key records by `terminalId`, and include `tabId`, `paneId`, `candidateThreadId`, `rolloutPath`, `state`, `capturedAt`, and `serverInstanceId`. + - [ ] Treat this server-side write as the authoritative gate-release persistence. Client localStorage persistence is still required for refresh/reopen UX, but it is not what releases PTY input. + - [ ] Make duplicate writes idempotent when `candidateThreadId` and `rolloutPath` match; reject mismatched rewrites for the same terminal. + - [ ] Delete records when the terminal is killed and either durable `sessionRef` was promoted or the candidate is intentionally abandoned. +- [ ] Add `test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts`. + - [ ] Success: first line is matching `session_meta`. + - [ ] Failure: missing path, directory, empty file, malformed first line, first line not `session_meta`, missing `payload.id`, mismatched id. + - [ ] Regression: a later matching line must not succeed if the first record is wrong. +- [ ] Add `test/unit/server/coding-cli/codex-app-server/durability-store.test.ts`. + - [ ] Atomic write/read round trip. + - [ ] Duplicate matching candidate is idempotent. + - [ ] Mismatched candidate for the same terminal is rejected. + - [ ] Missing older persisted layouts with no Codex durability data read cleanly and never synthesize a candidate from `resumeSessionId`. + +Run: + +```bash +npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts test/unit/server/coding-cli/codex-app-server/durability-store.test.ts --run +``` + +Commit: + +```bash +git add shared/codex-durability.ts server/coding-cli/codex-app-server/durability-proof.ts server/coding-cli/codex-app-server/durability-store.ts test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts test/unit/server/coding-cli/codex-app-server/durability-store.test.ts +git commit -m "Add Codex rollout durability proof reader" +``` + +### 2. Add App-Server Event Schemas For Turns + +- [ ] Update `server/coding-cli/codex-app-server/protocol.ts`. + - [ ] Add `CodexTurnStartedNotificationSchema` if the protocol surface is present in the observed app-server traffic or fake server tests need it. + - [ ] Add `CodexTurnCompletedNotificationSchema` for `method: "turn/completed"` with `params.threadId` and pass-through turn fields. + - [ ] Export inferred types. + - [ ] Keep lifecycle parsing separate from turn parsing so lifecycle loss recovery behavior remains unchanged. +- [ ] Update `server/coding-cli/codex-app-server/client.ts`. + - [ ] Add `onTurnStarted` and `onTurnCompleted` handlers. + - [ ] Dispatch turn events from notification parsing before generic handling. + - [ ] Preserve existing `thread/started`, lifecycle loss, disconnect, and `fs/changed` behavior. +- [ ] Update `server/coding-cli/codex-app-server/runtime.ts`. + - [ ] Re-emit client turn events with `onTurnStarted` and `onTurnCompleted`. +- [ ] Add/update unit tests in: + - [ ] `test/unit/server/coding-cli/codex-app-server/client.test.ts` + - [ ] `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` + +Run: + +```bash +npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts --run +``` + +Commit: + +```bash +git add server/coding-cli/codex-app-server/protocol.ts server/coding-cli/codex-app-server/client.ts server/coding-cli/codex-app-server/runtime.ts test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts +git commit -m "Observe Codex turn lifecycle notifications" +``` + +### 3. Add A Freshell-Owned Codex Remote Websocket Proxy + +- [ ] Create `server/coding-cli/codex-app-server/remote-proxy.ts`. + - [ ] Allocate a loopback websocket endpoint for the visible TUI. + - [ ] Forward TUI websocket traffic to the real app-server sidecar endpoint. + - [ ] Observe client-to-server JSON-RPC requests and remember request id to method for `thread/start`, `thread/resume`, `turn/start`, and any turn methods present in fixtures. + - [ ] Parse client-to-server `turn/start` as a generic JSON-RPC envelope by method name; do not require a full request-params Zod schema unless the implementation needs fields beyond the method and id. + - [ ] Observe server-to-client JSON-RPC responses. For a `thread/start` response, parse the `thread` payload and emit candidate `{ threadId, rolloutPath, source: "thread_start_response" }`. + - [ ] Observe server-to-client notifications. Emit candidate from `thread/started` if no response candidate has been persisted yet; emit `turn_started`, `turn_completed`, `fs_changed`, lifecycle loss, and connection loss events. + - [ ] If a `turn/start` request arrives before the server-side candidate persistence write completes, hold that request until the write completes. If the write fails, the terminal is shutting down, or `5_000ms` elapse without a persisted candidate, fail the held request with JSON-RPC error code `-32000` and message `Freshell could not persist Codex restore identity before accepting user input.` Transition the terminal to `non_restorable`, stop that fresh TUI, and fresh-create only if the user explicitly retries. + - [ ] Also start a candidate-capture deadline when the visible TUI is spawned, independent of user input. If no candidate has been persisted within `45_000ms`, transition the terminal to `non_restorable`, emit `terminal.codex.durability.updated`, send `terminal.input.blocked` with terminal reason `codex_identity_capture_timeout` for any later input, and stop the fresh TUI/sidecar. This is a bounded startup deadline, not polling; live production validation showed a 10 second deadline can kill a valid cold Codex launch before identity capture completes. + - [ ] Apply the candidate-capture deadline and `turn/start` hold only to fresh Codex launches that do not yet have a canonical durable `sessionRef`. Durable resume launches still pass through the proxy for turn/lifecycle observation, but the proxy must start with candidate persistence disabled, must not arm the fresh-candidate timeout, and must not hold `turn/start`. + - [ ] On held `turn/start` failure or candidate-capture timeout, return the JSON-RPC error if a request is pending, then close the proxy websocket and kill the PTY process for that failed fresh TUI. Do not leave Codex running against a dead or untrusted proxy, and do not replay held user bytes into a replacement session. + - [ ] Do not periodically query the app-server or filesystem from the proxy. + - [ ] Include structured logs for proxy start, candidate observed, held turn request, released turn request, turn completed, proof trigger, and proxy close/error. +- [ ] Ensure readiness ordering is explicit. + - [ ] `CodexRemoteProxy.start()` must resolve only after the local proxy websocket server is listening and all local event handlers are installed. + - [ ] `launch-planner.ts` must await proxy readiness before returning the plan that will spawn the visible TUI. + - [ ] Freshell-owned upstream observer/listener setup must complete before `buildSpawnSpec()` can hand the proxy URL to Codex. +- [ ] Add `test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts`. + - [ ] Fresh TUI traffic through the proxy captures candidate from `thread/start` response. + - [ ] Candidate can also be captured from `thread/started` notification. + - [ ] `turn/start` before server-side candidate persistence is held, then forwarded after the store write completes. + - [ ] `turn/start` times out and fails cleanly if candidate persistence never completes. + - [ ] Candidate-capture timeout fires even when the user never types and no `turn/start` request arrives. + - [ ] Durable resume proxy traffic forwards `turn/start` immediately and does not emit candidate-capture timeout when no fresh candidate is expected. + - [ ] Timeout/failure closes the proxy websocket and terminates the failed TUI rather than leaving it running. + - [ ] `turn/completed` is emitted with the matching thread id. + - [ ] Proxy close/error emits a deterministic repair trigger and shuts down without leaking sockets. + +Run: + +```bash +npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts --run +``` + +Commit: + +```bash +git add server/coding-cli/codex-app-server/remote-proxy.ts test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts +git commit -m "Proxy Codex remote traffic for deterministic identity capture" +``` + +### 4. Replace Fresh Codex Pre-Create/Resume With Fresh Remote Launch + +- [ ] Update `server/coding-cli/codex-app-server/launch-planner.ts`. + - [ ] For fresh Codex launch, call `runtime.ensureReady()` instead of `runtime.startThread()`. + - [ ] Start and await a `CodexRemoteProxy` before returning the plan. + - [ ] Return a launch plan whose `sessionId` is undefined for fresh launches. The fresh visible command must be `codex --remote <proxyWsUrl>` with no `resume <threadId>`. + - [ ] For durable resume launches, keep `sessionId == resumeSessionId`, route the TUI through the proxy, and keep readiness behavior for the durable id. + - [ ] Durable resume launches start in `durable_resuming`/`durable`; they construct the proxy with fresh-candidate persistence disabled, do not arm candidate-capture timeout, and do not re-promote on `thread/started`. + - [ ] Sidecar shutdown must close the proxy and the runtime sidecar. + - [ ] Sidecar adoption must still update sidecar ownership metadata with terminal id and generation. + - [ ] Expose proxy events on the sidecar: `onCandidate`, `markCandidatePersisted`, `onTurnStarted`, `onTurnCompleted`, `onRepairTrigger`, `onLifecycleLoss`, and `onFsChanged`. +- [ ] Update `server/ws-handler.ts`. + - [ ] Do not overwrite `effectiveResumeSessionId` with a fresh Codex plan id when the launch is fresh. + - [ ] Continue passing `effectiveResumeSessionId` only for proven durable resumes. + - [ ] Record lifecycle events distinguishing `codex_candidate_pending`, `codex_candidate_captured`, and `codex_durable_session_observed`. + - [ ] Remove the existing adoption-time `codex_durable_session_observed` emission for fresh Codex. That event must be emitted only after rollout proof success; durable resume can log `codex_durable_resume_started`. +- [ ] Update `server/terminal-registry.ts` recovery spawning. + - [ ] Durable recovery still spawns `resume <durableThreadId>`. + - [ ] Fresh launch never spawns `resume <candidateThreadId>`. +- [ ] Update `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`. + - [ ] Fresh `planCreate({ cwd })` must not call `startThread`. + - [ ] Fresh launch plan remote wsUrl is the proxy wsUrl. + - [ ] Fresh plan has no durable `sessionId`. + - [ ] Durable `planCreate({ resumeSessionId })` uses resume id and readiness as before. +- [ ] Update `test/unit/server/ws-handler-sdk.test.ts` or add a focused server WS test. + - [ ] Fresh `terminal.create` for Codex does not return `effectiveResumeSessionId`. + - [ ] Durable `terminal.create` for Codex still returns the durable resume id. + +Run: + +```bash +npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts test/unit/server/ws-handler-sdk.test.ts --run +``` + +Commit: + +```bash +git add server/coding-cli/codex-app-server/launch-planner.ts server/ws-handler.ts server/terminal-registry.ts test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts test/unit/server/ws-handler-sdk.test.ts +git commit -m "Launch fresh Codex without pre-durable resume" +``` + +### 5. Persist Candidate State Before Releasing Input + +- [ ] Update `shared/ws-protocol.ts`. + - [ ] Add server-to-client `terminal.codex.durability.updated` payload carrying `terminalId` and `CodexDurabilityRef`. + - [ ] Add client-to-server `terminal.codex.candidate.persisted` with `terminalId`, `candidateThreadId`, `rolloutPath`, and `capturedAt`. + - [ ] Register `terminal.codex.candidate.persisted` in every server-side websocket validator, including the dynamic schema built by `server/ws-handler.ts`, so browser acknowledgements cannot be rejected as `INVALID_MESSAGE`. + - [ ] Add optional `codexDurability` to `terminal.create` so persisted captured-but-unproven panes can be repaired or fresh-created deterministically on reopen. + - [ ] Add server-to-client `terminal.input.blocked` with `reason: "codex_identity_pending"` for diagnostic UI/logging when PTY input arrives during the narrow gate. Do not send `INVALID_TERMINAL_ID` for gated input. +- [ ] Update `src/store/paneTypes.ts`, `src/store/types.ts`, `src/store/persistedState.ts`, `src/store/storage-migration.ts`, `src/store/panesSlice.ts`, and `src/store/tabsSlice.ts`. + - [ ] Add optional `codexDurability?: CodexDurabilityRef` to terminal pane content and tab metadata. + - [ ] Persist it in localStorage. + - [ ] Preserve it across tab/pane merge logic. + - [ ] When canonical `sessionRef.provider == "codex"` is set for the same thread id, retain durable proof metadata if available with `state: "durable"` and clear only degraded/pending warnings. Do not keep a stale non-canonical pending state next to a matching canonical `sessionRef`. + - [ ] Do not backfill it from `resumeSessionId`, cwd, title, or time. + - [ ] Add a named migration test: older persisted layouts with no `codexDurability` field must load cleanly and must not synthesize candidate state from `resumeSessionId`. +- [ ] Update `src/components/TerminalView.tsx`. + - [ ] On `terminal.codex.durability.updated`, update pane content with the candidate/degraded state and flush persisted layout immediately. + - [ ] After flush succeeds, send `terminal.codex.candidate.persisted` for candidate states. This acknowledgement is idempotent and observational; it must not be required for server-side gate release because the server-side durability store is authoritative. + - [ ] On `terminal.session.associated`, clear matching `codexDurability` and set the canonical `sessionRef` through the existing durable path. + - [ ] Include persisted `codexDurability` in `terminal.create` when there is no canonical Codex `sessionRef`. + - [ ] Do not send a candidate thread id as `resumeSessionId`. + - [ ] On `terminal.input.blocked`, log the blocked reason and show a throttled terminal-local message so browser-originated input is not silently dropped during the identity gate. +- [ ] Update `server/terminal-registry.ts`. + - [ ] Extend `TerminalRecord` with `codexDurability` and `codexInputGate`. + - [ ] When proxy emits a candidate, write it to the server-side durability store first. After that atomic write succeeds, transition to `captured_pre_turn`, emit `terminal.codex.durability.updated`, call sidecar/proxy `markCandidatePersisted()`, and release held `turn/start` requests. + - [ ] If the candidate store write fails, do not release PTY input or held `turn/start`. Mark the terminal `non_restorable`, log the failure, and keep user work from entering an untracked Codex session. + - [ ] Change `input()` to return `TerminalInputResult`: + - [ ] `{ status: "written" }` + - [ ] `{ status: "blocked_codex_identity_pending"; terminalId: string }` + - [ ] `{ status: "blocked_codex_identity_capture_timeout"; terminalId: string }` + - [ ] `{ status: "blocked_codex_identity_unavailable"; terminalId: string; reason?: string }` + - [ ] `{ status: "blocked_codex_recovery_pending"; terminalId: string }` + - [ ] `{ status: "no_terminal" }` + - [ ] `{ status: "not_running" }` + - [ ] Update all callers of `TerminalRegistry.input()` to handle the new result shape. + - [ ] Keep terminal resize and output flowing while input is gated. + - [ ] Duplicate or replayed client `terminal.codex.candidate.persisted` acknowledgements succeed only when they match the stored candidate; mismatched acks are logged and ignored. +- [ ] Update automation prompt-seeding surfaces. + - [ ] For `freshell new-tab --codex --prompt ...` and MCP `new-tab` with `mode: "codex"` and `prompt`, opt into an event-driven `send-keys` wait. + - [ ] The server retries the prompt when a matching `terminal.codex.durability.updated` or terminal exit event arrives. Do not poll, and do not send the prompt while the terminal is still `identity_pending`. +- [ ] Update `server/ws-handler.ts`. + - [ ] Handle `terminal.codex.candidate.persisted` and call the registry acknowledgement method. + - [ ] For blocked input, log at debug/info with terminal id and bytes, send `terminal.input.blocked`, and do not misreport it as `INVALID_TERMINAL_ID`. + - [ ] Blocked input is dropped, not buffered and not replayed. The user can type again after the gate opens; Freshell must not silently submit stale pre-capture bytes. +- [ ] Add/update tests: + - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: input is blocked before server-side candidate persistence and written after the store write completes. + - [ ] `test/unit/client/components/TerminalView.test.tsx` or nearest focused test: candidate update persists and sends ack; canonical association clears candidate state. + - [ ] `test/unit/client/store/*`: persisted state keeps `codexDurability`. + +Run: + +```bash +npm run test:vitest -- test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/client/components/TerminalView.test.tsx test/unit/client/store --run +``` + +Commit: + +```bash +git add shared/ws-protocol.ts shared/codex-durability.ts src/store src/components/TerminalView.tsx server/terminal-registry.ts server/ws-handler.ts test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/client +git commit -m "Persist Codex candidate identity before accepting input" +``` + +### 6. Promote Durable Codex Identity At Turn Completion + +- [ ] Update `server/terminal-registry.ts`. + - [ ] Subscribe each Codex sidecar/proxy to `turn_started`, `turn_completed`, `fs_changed`, proxy close/error, and lifecycle loss events. + - [ ] On `turn_started` for the candidate, transition to `turn_in_progress_unproven`. + - [ ] On `turn_completed` for the candidate, transition to `proof_checking`, run one `proofCodexRollout()` call, and then: + - [ ] Success: transition to `durable`, bind `resumeSessionId` through the existing `bindSessionToTerminal()` path, emit `terminal.session.associated`, record `codex_durable_session_observed`, and clear candidate state in the client. + - [ ] Failure: transition to `durability_unproven_after_completion`, emit `terminal.codex.durability.updated`, keep the live PTY attachable if running, and log structured proof failure data. + - [ ] Coalesce overlapping deterministic proof triggers into at most one extra immediate proof read after the current one finishes. Do not use `setInterval`, delayed backoff loops, or path-existence polling (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:526`). + - [ ] Watch the exact rollout path and parent through the Freshell-owned runtime/client connection, not by injecting requests into the TUI proxy socket. This avoids JSON-RPC request-id collisions with the visible TUI. Treat `fs/changed` only as a repair trigger that calls the same proof reader (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:482-490`, `:528-538`). + - [ ] On PTY exit or app-server/proxy close/error, run one proof read before finalizing state. +- [ ] Update `server/ws-handler.ts`. + - [ ] Ensure `terminal.session.associated` is sent only after proof success for fresh Codex. + - [ ] Ensure `sendError` logs server-side structured errors for `RESTORE_UNAVAILABLE` and Codex proof failures. This closes the silent logging gap from the original observation. +- [ ] Add/update tests: + - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: `turn_completed` plus matching rollout emits canonical `terminal.session.associated`. + - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: `turn_completed` plus missing/malformed/mismatched rollout emits degraded state and does not bind `resumeSessionId`. + - [ ] Test trigger coalescing: two repair events during a proof read cause one extra read, not an unbounded loop. + - [ ] Test PTY exit before/after turn completion. + +Run: + +```bash +npm run test:vitest -- test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts --run +``` + +Commit: + +```bash +git add server/terminal-registry.ts server/ws-handler.ts test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts +git commit -m "Promote Codex sessions only after rollout proof" +``` + +### 7. Reopen Captured-But-Unproven State Deterministically + +- [ ] Update `server/ws-handler.ts` create/reuse flow. + - [ ] Ensure all user restore/list/open surfaces funnel through this create/reuse decision: sidebar row click, tab restore, background terminal restore, MCP/new-tab restore, and any history/session open path that creates a Codex terminal. + - [ ] When `terminal.create` includes `codexDurability` and no canonical `sessionRef`, ask the registry to run one proof read before deciding how to open. + - [ ] Permit `restore: true` for Codex candidate-only requests when `codexDurability.candidate` is present, even without `sessionRef`, so the proof-first path runs instead of rejecting the request before repair. + - [ ] Reopen of `durability_unproven_after_completion` follows the same proof-first path as captured-but-unproven. Success promotes; failure with an exact live candidate attaches live and remains degraded; failure with no live attachable terminal becomes `non_restorable` and fresh-creates only for a new Codex session. + - [ ] If proof succeeds, set `effectiveResumeSessionId` to the proven `durableThreadId` and launch a durable resume. + - [ ] If proof fails and a live terminal on this server matches the exact candidate thread id and rollout path, attach that live terminal and keep degraded/unproven state visible. + - [ ] If proof fails and no live terminal is attachable, fresh-create a new Codex terminal. Do not pass the candidate as `resumeSessionId`; attach a clear local restore-error/non-restorable state to the pane. +- [ ] Add `TerminalRegistry.findRunningCodexTerminalByCandidate({ candidateThreadId, rolloutPath })`. + - [ ] Match only exact candidate thread id and exact rollout path stored in the live record. + - [ ] Do not match by cwd, time, title, or shell snapshot (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`). +- [ ] Update client sidebar/state rendering. + - [ ] A live open terminal can show a live/attached indicator only from terminal inventory. + - [ ] A Codex pane/session must not show normal restorable/durable state until canonical `sessionRef` exists. + - [ ] `durability_unproven_after_completion` shows degraded/restoration-not-proven state even if live terminal attach is available. + - [ ] Newly created Codex panes appear in the sidebar immediately as live pending/captured rather than generic grey entries. + - [ ] Own the state-to-sidebar mapping in `src/store/selectors/sidebarSelectors.ts` and render it in `src/components/Sidebar.tsx` or the row component it delegates to: + - [ ] `identity_pending`: "Starting Codex; restore identity not captured." + - [ ] `captured_pre_turn`: "Codex identity captured; restore proof pending." + - [ ] `turn_in_progress_unproven`: "Codex turn running; restore proof pending." + - [ ] `proof_checking`: "Checking Codex restore proof." + - [ ] `durability_unproven_after_completion`: "Codex restore proof failed after turn completion." + - [ ] `non_restorable`: "Codex session could not be proven restorable." + - [ ] `durable` / `durable_resuming`: normal Codex restorable display. +- [ ] Add tests: + - [ ] Server: captured unproven reopen proof success resumes durable id. + - [ ] Server: captured unproven reopen proof fail plus live exact candidate attaches live and stays degraded. + - [ ] Server: captured unproven reopen proof fail plus no live exact candidate fresh-creates without passing candidate to resume. + - [ ] Server websocket tests must exercise the real client shape with `restore: true` and candidate-only `codexDurability`, not only raw `terminal.create` messages without restore semantics. + - [ ] Client/sidebar: live pending Codex appears as Codex, not a generic grey terminal; durable promotion updates the same entry rather than adding a duplicate. + - [ ] Each restore/list/open surface above uses the same proof-first path and has no independent cwd/time/title matching. + +Run: + +```bash +npm run test:vitest -- test/unit/server/ws-handler-sdk.test.ts test/unit/server/terminal-registry.findRunningTerminal.test.ts test/unit/client --run +``` + +Commit: + +```bash +git add server/ws-handler.ts server/terminal-registry.ts src test +git commit -m "Repair captured Codex reopen without nondeterministic matching" +``` + +### 8. Extend Fake Codex Fixtures For Realistic Tests + +- [ ] Update `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs`. + - [ ] Keep current app-server fixture mode for `app-server --listen`. + - [ ] Add fake TUI mode for `--remote <ws>` that connects to the proxy, sends `thread/start` for fresh launch, writes a visible PTY banner, reads stdin, sends `turn/start`, optionally writes rollout JSONL, then sends `turn/completed`. + - [ ] Add fixture controls for delayed candidate, missing rollout, malformed rollout, mismatched rollout id, delayed `turn/completed`, proxy close, and app-server close. + - [ ] Ensure fixture processes are tagged with temp env vars so cleanup cannot kill real user sessions. +- [ ] Add or update e2e/integration tests: + - [ ] Fresh Codex launch: candidate captured, input initially gated, server-side candidate persistence releases input, `turn/completed` promotes to canonical `sessionRef`. + - [ ] Missing rollout after `turn/completed`: state becomes degraded and no canonical `sessionRef` is persisted. + - [ ] Reopen degraded with later rollout proof: proof-read repairs and resumes durable id. + - [ ] Reopen degraded without proof after server restart: fresh-creates Codex and does not call `resume <candidateThreadId>`. + - [ ] Duplicate sidebar regression: a new live Codex terminal stays one sidebar item before and after durable promotion. + +Run: + +```bash +npm run test:vitest -- test/e2e/codex-session-resilience-flow.test.tsx test/e2e/codex-refresh-rehydrate-flow.test.tsx --run +``` + +Commit: + +```bash +git add test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs test/e2e +git commit -m "Cover Codex durability flow end to end" +``` + +### 9. Observability And Logging + +- [ ] Update `server/session-lifecycle-logger.ts` or the nearest lifecycle telemetry module. + - [ ] Add lifecycle event kinds for `codex_candidate_observed`, `codex_candidate_persist_requested`, `codex_candidate_persisted`, `codex_input_gate_blocked`, `codex_turn_completed`, `codex_rollout_proof_success`, `codex_rollout_proof_failure`, `codex_repair_triggered`, `codex_reopen_fresh_created`. +- [ ] Update `server/ws-handler.ts` `sendError`. + - [ ] Log every server-sent error with code, message, requestId, terminalId/session id when present, and connection id. + - [ ] Avoid relying on stdout/stderr-only messages from child processes; structured server logs should show the reason Freshell chose degraded/fresh-create/restore-unavailable. +- [ ] Add log assertions in focused unit tests where practical, especially for proof failure and restore-unavailable paths. + +Run: + +```bash +npm run test:vitest -- test/unit/server/ws-handler-sdk.test.ts test/unit/server/terminal-registry.codex-sidecar.test.ts --run +``` + +Commit: + +```bash +git add server test +git commit -m "Log Codex durability transitions and server errors" +``` + +### 10. Broad Verification + +- [ ] Run typecheck and focused tests: + +```bash +npm run typecheck +npm run test:vitest -- test/unit/server/coding-cli/codex-app-server test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/client test/e2e/codex-session-resilience-flow.test.tsx test/e2e/codex-refresh-rehydrate-flow.test.tsx --run +``` + +- [ ] Run coordinated full check when focused tests are green: + +```bash +FRESHELL_TEST_SUMMARY="codex turn-completion durability implementation" npm run check +``` + +- [ ] Commit any fixes: + +```bash +git status --short +git add <changed-files> +git commit -m "Stabilize Codex durability verification" +``` + +### 11. Review Hardening Items + +These checks come from the implementation reviews and are part of the same one-shot delivery, not follow-up work. + +- [ ] Arm fresh Codex candidate-capture timeout when the proxy is ready, even if the visible Codex TUI never connects. This closes the stuck `identity_pending` state described in the research evidence that input must not be accepted until Freshell has server-persisted Codex's reported restore identity (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract"). +- [ ] Initialize durable Codex resume records as durable in `TerminalRegistry.create()` when the caller supplies a canonical `sessionRef`. The research says `sessionRef` is the only durable restore identity; a terminal created from one must advertise that same identity through inventory and sidebar state (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Recommendation"). +- [ ] On final Codex process loss, run exactly one rollout proof if a candidate exists, even if the `turn/completed` notification was lost. Ordinary repair events still wait for `turn/completed`; final loss is the last chance to avoid falsely discarding a restorable session (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "What remains unproven"). +- [ ] Preserve captured candidate state across browser refresh and use it for the recreate request after the old live terminal id is gone. This is the client-side half of "prefer terminal, then proof-read candidate, then fresh-create if proof fails" (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling"). +- [ ] Extend the fake app-server/fake TUI integration path so tests exercise actual proxy candidate capture, input, `turn/completed`, rollout proof, durable promotion, and sidebar/inventory exposure instead of only direct sidecar callbacks. +- [ ] Delete transient Codex durability store records when the owning terminal is killed, removed, or reaped. The server-side store is a crash bridge for an active terminal, not a durable session database. +- [ ] If rollout proof succeeds but canonical session binding fails, do not broadcast `terminal.session.associated`; mark the terminal non-restorable instead so the client cannot persist a session the server does not own. +- [ ] After an async candidate-store write completes, re-check that the same terminal is still running and still accepting a candidate before mutating in-memory state or calling `markCandidatePersisted()`. +- [ ] Report input after a candidate-capture timeout as `terminal.input.blocked` with `codex_identity_capture_timeout`, not as a generic invalid/dead terminal. +- [ ] Treat candidate-only Codex ids as pane/tab locators only. They may focus an existing pane so the sidebar does not duplicate entries, but they must not become `sessionRef`, `resumeSessionId`, or tab session metadata until rollout proof succeeds. This follows the research distinction between candidate identity and durable restore identity (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`). +- [ ] When a non-restorable Codex row has no live terminal to attach, open a fresh Codex pane without carrying the old candidate id. This preserves user ergonomics without pretending restore succeeded (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`). +- [ ] Reject raw Codex resume ids from generic automation surfaces (`/api/tabs`, pane split/respawn, and MCP `new-tab`) unless they are already flowing through the proven canonical `sessionRef` path. These surfaces do not carry rollout proof, so they must fresh-create instead of invoking `codex resume <candidateThreadId>` (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:369-412`, `:474-480`, `:550`). +- [ ] Expose Codex durability state in sidebar rows, not just a boolean. Pending, checking, degraded, and non-restorable Codex rows must be distinguishable while durable rows remain normal. The research explicitly rejects green/grey/live-only ambiguity after proof failure (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:506-518`, `:584-590`). +- [ ] Arm an exact app-server `fs/watch` on the captured rollout path as a wake-up source after candidate persistence, and unwatch it after durable promotion or sidecar teardown. The research says `fs/watch` cannot be the only proof path, but it remains the deterministic repair trigger when the rollout appears after the first proof attempt (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:325-331`, `:482-490`, `:554`, `:586-588`). +- [ ] When attaching an existing live non-restorable Codex terminal by `terminalId`, carry its full `codexDurability` into the reopened tab and pane. A degraded live session is still failure, but Freshell must retain the evidence needed for proof-first repair instead of collapsing it into a generic grey terminal (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling"). +- [ ] Allow canonical Codex `sessionRef` through automation surfaces while continuing to ignore legacy raw Codex `resumeSessionId`. The durable path is the canonical session contract; the unsafe path is treating an unproven candidate or legacy resume token as durable (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`). +- [ ] Make rollout proof read only the first JSONL record, not the full rollout file. The proof contract is first-record `session_meta.payload.id`, so proof should be O(first line) rather than O(full transcript) (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:456`, `:520-526`). + +## Temporary Server Validation + +Use a port that does not interfere with dev, for example `3477`. Do not restart `/home/user/code/freshell/.worktrees/dev`. + +- [ ] Build from the implementation worktree: + +```bash +npm run build +``` + +- [ ] Start a temporary server from this worktree only: + +```bash +PORT=3477 npm start > /tmp/freshell-codex-durability-3477.log 2>&1 & echo $! > /tmp/freshell-codex-durability-3477.pid +``` + +- [ ] Verify the PID belongs to this worktree before stopping it later: + +```bash +ps -fp "$(cat /tmp/freshell-codex-durability-3477.pid)" +``` + +- [ ] Use Freshell orchestration against `http://127.0.0.1:3477` and run each fixture-backed scenario at least three times: + - [ ] Fresh Codex pane: before candidate capture, user input is not accepted but terminal-control replies needed for TUI startup are allowed; after server-side candidate persistence, user input works. + - [ ] Fresh Codex pane with fake TUI: send a test prompt, wait for fake `turn/completed`, verify canonical `sessionRef` is persisted and sidebar entry remains a single Codex item. + - [ ] Fresh Codex pane: close/reopen after durable promotion, verify it resumes with `codex --remote <proxy> resume <durableThreadId>`. + - [ ] Fresh Codex pane: reload browser before first turn completes, verify candidate state is preserved and no candidate id is used as `resumeSessionId`. + - [ ] Fresh Codex pane: restart only the temporary server after durable promotion, verify reopen resumes durable id. + - [ ] Fresh Codex pane: simulate/mutate missing rollout after `turn/completed`, verify degraded state and no fake green/normal state. + - [ ] Captured-but-unproven pane after temporary server restart: verify Freshell proof-reads once and fresh-creates if proof fails, without trying `codex resume <candidateThreadId>`. + - [ ] Existing durable Codex pane: sidecar lifecycle loss triggers durable recovery and preserves the same sidebar item. + - [ ] Shell and Claude panes: create, type, close/reopen, and server restart to verify Codex changes did not regress non-Codex terminal state. +- [ ] Run a real Codex smoke only if the machine has working Codex auth and model access: + - [ ] Fresh real Codex pane: send a short harmless prompt, wait for completion, verify canonical `sessionRef` is persisted and restore works. + - [ ] If real Codex auth/model access is unavailable, record the skipped reason and rely on fixture-backed scenarios plus unit/integration coverage. + +- [ ] Inspect `/tmp/freshell-codex-durability-3477.log`. + - [ ] Confirm structured events exist for candidate observed, candidate persisted, input gate release, turn completed, proof success/failure, and reopen decisions. + - [ ] Confirm no proof-read polling loop is visible. + - [ ] Confirm no server-sent errors are silent. + +- [ ] Stop only the temporary server: + +```bash +kill "$(cat /tmp/freshell-codex-durability-3477.pid)" && rm -f /tmp/freshell-codex-durability-3477.pid +``` + +## Done Criteria + +- Fresh Codex launch no longer pre-creates an app-server thread and no longer TUI-resumes a pre-durable id. +- User-originating Codex input is blocked only until Freshell has persisted the candidate thread id and rollout path. +- `turn/completed` triggers one exact proof read of the provider-reported rollout path. +- Canonical Codex `sessionRef` is persisted only after first-record `session_meta.payload.id` matches the candidate thread id. +- Captured-but-unproven Codex reopen never matches by cwd/time/title and never tries `codex resume <candidateThreadId>` before proof. +- New live Codex panes appear in the sidebar immediately as Codex entries, do not stay generic grey, and do not duplicate on promotion. +- Post-completion proof failure is visible degraded state, not a normal grey/green state. +- Unit, integration/e2e, coordinated check, and repeated temporary-server scenarios pass. diff --git a/extensions/opencode/freshell.json b/extensions/opencode/freshell.json index 1bfb4482e..f3b946e43 100644 --- a/extensions/opencode/freshell.json +++ b/extensions/opencode/freshell.json @@ -19,7 +19,7 @@ "supportsModel": true, "terminalBehavior": { "preferredRenderer": "canvas", - "scrollInputPolicy": "fallbackToCursorKeysWhenAltScreenMouseCapture" + "scrollInputPolicy": "native" } }, "picker": { diff --git a/package-lock.json b/package-lock.json index 75df5123d..e5166f884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,9 @@ "typescript": "^5.7.2", "vite": "^6.4.1", "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.5.0" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 1b2b1f3cd..c1e1b8172 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dev": "cross-env PORT=3002 concurrently -n client,server -c blue,green \"vite\" \"tsx watch server/index.ts\"", "dev:client": "vite", "dev:server": "cross-env PORT=3002 tsx watch server/index.ts", + "dev:queue": "tsx scripts/dev-pr-queue.ts", + "selfhost:check-branch": "tsx scripts/selfhost-branch.ts validate-launch", "typecheck": "npm run typecheck:client && npm run typecheck:server", "typecheck:client": "tsc -p tsconfig.json --noEmit", "typecheck:server": "tsc -p tsconfig.server.json --noEmit", diff --git a/scripts/audit-codex-app-server-schema.ts b/scripts/audit-codex-app-server-schema.ts new file mode 100644 index 000000000..c55800d11 --- /dev/null +++ b/scripts/audit-codex-app-server-schema.ts @@ -0,0 +1,72 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { + CODEX_CLIENT_REQUEST_METHODS, + CODEX_RUNTIME_LEAF_VALUES, + CODEX_SCHEMA_VERSION, + CODEX_SERVER_NOTIFICATION_METHODS, + CODEX_SERVER_REQUEST_METHODS, + CODEX_THREAD_ITEM_VARIANTS, +} from '../test/fixtures/coding-cli/codex-app-server/schema-inventory.js' + +type JsonSchema = { + oneOf?: Array<{ properties?: { method?: { enum?: string[] }; type?: { const?: string; enum?: string[] } } }> + definitions?: Record<string, JsonSchema & { enum?: string[] }> + enum?: string[] +} + +function readSchema(filePath: string): JsonSchema { + return JSON.parse(readFileSync(filePath, 'utf8')) as JsonSchema +} + +function methods(filePath: string): string[] { + return (readSchema(filePath).oneOf ?? []) + .map((entry) => entry.properties?.method?.enum?.[0]) + .filter((value): value is string => Boolean(value)) +} + +function threadItemVariants(filePath: string): string[] { + const schema = readSchema(filePath).definitions?.ThreadItem + return (schema?.oneOf ?? []) + .map((entry) => entry.properties?.type?.const ?? entry.properties?.type?.enum?.[0]) + .filter((value): value is string => Boolean(value)) +} + +function compare(label: string, expected: readonly string[], actual: readonly string[]): string[] { + const missing = expected.filter((value) => !actual.includes(value)) + const added = actual.filter((value) => !expected.includes(value)) + const messages: string[] = [] + if (missing.length > 0) messages.push(`${label} missing from generated schema: ${missing.join(', ')}`) + if (added.length > 0) messages.push(`${label} added by generated schema: ${added.join(', ')}`) + return messages +} + +const workDir = mkdtempSync(path.join(tmpdir(), 'freshell-codex-schema-audit-')) +try { + const jsonDir = path.join(workDir, 'json') + execFileSync('codex', ['app-server', 'generate-json-schema', '--out', jsonDir], { stdio: 'inherit' }) + + const failures = [ + ...compare('client request methods', CODEX_CLIENT_REQUEST_METHODS, methods(path.join(jsonDir, 'ClientRequest.json'))), + ...compare('server request methods', CODEX_SERVER_REQUEST_METHODS, methods(path.join(jsonDir, 'ServerRequest.json'))), + ...compare('server notification methods', CODEX_SERVER_NOTIFICATION_METHODS, methods(path.join(jsonDir, 'ServerNotification.json'))), + ...compare('thread item variants', CODEX_THREAD_ITEM_VARIANTS, threadItemVariants(path.join(jsonDir, 'codex_app_server_protocol.v2.schemas.json'))), + ] + + const v2Schema = readSchema(path.join(jsonDir, 'codex_app_server_protocol.v2.schemas.json')) + const generatedReasoningEffort = v2Schema.definitions?.ReasoningEffort?.enum ?? [] + failures.push(...compare('reasoning effort values', CODEX_RUNTIME_LEAF_VALUES.reasoningEffort, generatedReasoningEffort)) + + if (failures.length > 0) { + console.error(`Codex app-server schema inventory is stale. Checked-in inventory version: ${CODEX_SCHEMA_VERSION}`) + for (const failure of failures) console.error(`- ${failure}`) + process.exit(1) + } + + console.log(`Codex app-server schema inventory matches checked-in ${CODEX_SCHEMA_VERSION} fixture.`) +} finally { + rmSync(workDir, { recursive: true, force: true }) +} diff --git a/scripts/dev-pr-queue.ts b/scripts/dev-pr-queue.ts new file mode 100644 index 000000000..6766c7e70 --- /dev/null +++ b/scripts/dev-pr-queue.ts @@ -0,0 +1,283 @@ +#!/usr/bin/env tsx + +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) + +const REPO = 'danshapiro/freshell' +const PR_JSON_FIELDS = 'number,state,isDraft,baseRefName,headRefOid,mergeStateStatus,title,labels' +const EXCLUDED_LABELS = new Set([ + 'do-not-merge', + 'superseded', + 'approval-artifact', + 'approval-artifact-only', +]) + +export type DevQueuePr = { + number: number + state: 'OPEN' | 'CLOSED' | string + isDraft: boolean + baseRefName: string + headRefOid: string + mergeStateStatus: string + title: string + labels: Array<{ name: string }> +} + +export type DevQueuePlanStep = { + label: string + command: string[] +} + +export type DevQueuePlan = { + originMain: string + steps: DevQueuePlanStep[] +} + +export type DevQueueCommandRunner = (command: string, args: string[]) => Promise<string> + +export function parsePrList(input: string): number[] { + const prs = input + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => Number(part)) + + if (prs.length === 0 || prs.some((number) => !Number.isInteger(number) || number <= 0)) { + throw new Error('At least one PR number is required, formatted like 321,309,319.') + } + + return prs +} + +export function buildPrMetadataCommand(number: number): [string, string[]] { + return [ + 'gh', + [ + 'pr', + 'view', + String(number), + '--repo', + REPO, + '--json', + PR_JSON_FIELDS, + ], + ] +} + +export async function loadPrMetadata( + numbers: number[], + run: DevQueueCommandRunner, +): Promise<DevQueuePr[]> { + const prs: DevQueuePr[] = [] + for (const number of numbers) { + const [command, args] = buildPrMetadataCommand(number) + const stdout = await run(command, args) + try { + prs.push(JSON.parse(stdout) as DevQueuePr) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse gh metadata for PR #${number}: ${message}`) + } + } + return prs +} + +function validatePrForDev(pr: DevQueuePr): void { + if (pr.state !== 'OPEN') { + throw new Error(`PR #${pr.number} is ${pr.state}, expected OPEN.`) + } + if (pr.isDraft) { + throw new Error(`PR #${pr.number} is draft and is not pending for dev.`) + } + if (pr.baseRefName !== 'main') { + throw new Error(`PR #${pr.number} targets ${pr.baseRefName}, expected main.`) + } + + const excludedLabel = pr.labels.find((label) => EXCLUDED_LABELS.has(label.name.toLowerCase())) + if (excludedLabel) { + throw new Error(`PR #${pr.number} is labeled ${excludedLabel.name} and is not pending for dev.`) + } +} + +export function buildDevQueuePlan(input: { + originMain: string + requestedPrs: number[] + prs: DevQueuePr[] +}): DevQueuePlan { + const byNumber = new Map(input.prs.map((pr) => [pr.number, pr])) + + for (const number of input.requestedPrs) { + const pr = byNumber.get(number) + if (!pr) { + throw new Error(`PR #${number} was not found.`) + } + validatePrForDev(pr) + } + + const steps: DevQueuePlanStep[] = [ + { label: 'fetch-origin-main', command: ['git', 'fetch', 'origin', 'main'] }, + { label: 'reset-dev-to-origin-main', command: ['git', 'reset', '--hard', input.originMain] }, + ] + + for (const number of input.requestedPrs) { + steps.push({ + label: `fetch-pr-${number}`, + command: ['git', 'fetch', 'origin', `+refs/pull/${number}/head:refs/remotes/pr/${number}`], + }) + steps.push({ + label: `merge-pr-${number}`, + command: ['git', 'merge', '--no-ff', '--no-edit', `refs/remotes/pr/${number}`], + }) + } + + return { originMain: input.originMain, steps } +} + +export async function assertAssemblePreconditions(input: { + getBranch: () => Promise<string | undefined> + getStatus: () => Promise<string> +}): Promise<void> { + const branch = await input.getBranch() + if (branch !== 'dev') { + throw new Error(`Refusing to assemble dev from ${branch ?? 'an unknown branch'}. Switch to dev first.`) + } + + const status = await input.getStatus() + if (status.trim()) { + throw new Error('Refusing to reset dev with a dirty worktree. Commit, stash, or discard local changes first.') + } +} + +export async function executeDevQueuePlan( + plan: DevQueuePlan, + run: DevQueueCommandRunner, +): Promise<void> { + for (const step of plan.steps) { + try { + await run(step.command[0], step.command.slice(1)) + } catch (error) { + const mergePrMatch = step.label.match(/^merge-pr-(\d+)$/) + if (mergePrMatch) { + throw new Error(`PR #${mergePrMatch[1]} did not merge cleanly. Fix the PR branch, abort the merge, and rerun the dev queue.`) + } + + const cherryPickPrMatch = step.label.match(/^cherry-pick-pr-(\d+)$/) + if (cherryPickPrMatch) { + throw new Error(`PR #${cherryPickPrMatch[1]} did not cherry-pick cleanly. Fix the PR branch, abort the cherry-pick, and rerun the dev queue.`) + } + + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Step ${step.label} failed: ${message}`) + } + } +} + +export async function assembleDevQueue(input: { + requestedPrs: number[] + run: DevQueueCommandRunner + getBranch: () => Promise<string | undefined> + getStatus: () => Promise<string> +}): Promise<void> { + await assertAssemblePreconditions(input) + await input.run('git', ['fetch', 'origin', 'main']) + const originMain = await input.run('git', ['rev-parse', 'origin/main']) + const prs = await loadPrMetadata(input.requestedPrs, input.run) + const plan = buildDevQueuePlan({ originMain, requestedPrs: input.requestedPrs, prs }) + await executeDevQueuePlan(plan, input.run) +} + +const runCommand: DevQueueCommandRunner = async (command, args) => { + try { + const { stdout } = await execFileAsync(command, args, { encoding: 'utf8' }) + return stdout.trim() + } catch (error) { + if (error instanceof Error) { + const details = error as Error & { stderr?: string; stdout?: string } + const output = [details.stderr, details.stdout] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map((value) => value.trim()) + .join('\n') + if (output) { + throw new Error(output) + } + throw error + } + throw new Error(String(error)) + } +} + +function printUsage(): void { + console.log('Usage: tsx scripts/dev-pr-queue.ts plan --prs 321,309,319') + console.log(' tsx scripts/dev-pr-queue.ts assemble --prs 321,309,319') +} + +function parseCliPrList(argv: string[]): number[] { + const prsFlagIndex = argv.indexOf('--prs') + const prsValue = prsFlagIndex >= 0 ? argv[prsFlagIndex + 1] : '' + return parsePrList(prsValue) +} + +async function buildPlanForCli(requestedPrs: number[]): Promise<{ + plan: DevQueuePlan + prs: DevQueuePr[] +}> { + await runCommand('git', ['fetch', 'origin', 'main']) + const originMain = await runCommand('git', ['rev-parse', 'origin/main']) + const prs = await loadPrMetadata(requestedPrs, runCommand) + const plan = buildDevQueuePlan({ originMain, requestedPrs, prs }) + return { plan, prs } +} + +export async function main(argv: string[]): Promise<number> { + const command = argv[0] + if (!command || command === '--help' || command === '-h') { + printUsage() + return command ? 0 : 2 + } + + try { + const requestedPrs = parseCliPrList(argv) + + if (command === 'plan') { + const { plan, prs } = await buildPlanForCli(requestedPrs) + for (const step of plan.steps) { + console.log(`${step.label}: ${step.command.join(' ')}`) + } + console.log(`origin/main: ${plan.originMain}`) + for (const pr of prs) { + console.log(`PR #${pr.number}: ${pr.headRefOid}`) + } + return 0 + } + + if (command === 'assemble') { + console.log('This will reset local dev to origin/main before applying PRs.') + console.log('Refusing to continue unless current branch is dev and worktree is clean.') + await assembleDevQueue({ + requestedPrs, + run: runCommand, + getBranch: async () => { + const branch = await runCommand('git', ['branch', '--show-current']) + return branch || undefined + }, + getStatus: async () => runCommand('git', ['status', '--porcelain']), + }) + console.log('Local dev has been reset to origin/main and updated with the requested PR heads.') + return 0 + } + + console.error(`Unsupported command: ${command}`) + printUsage() + return 2 + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + return 1 + } +} + +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) { + main(process.argv.slice(2)).then((code) => process.exit(code)) +} diff --git a/scripts/launch.sh b/scripts/launch.sh index 8b7529a3f..5fa7a99a8 100755 --- a/scripts/launch.sh +++ b/scripts/launch.sh @@ -1,20 +1,29 @@ #!/usr/bin/env bash -# Launch Freshell: pull upstream, build, start server in background. +# Launch Freshell: build, start server in background. set -euo pipefail FRESHELL_DIR="$(cd "$(dirname "$0")/.." && pwd)" -FRESHELL_HOME="$HOME/.freshell" +FRESHELL_HOME="${FRESHELL_HOME:-$HOME/.freshell}" LOG_FILE="$FRESHELL_HOME/logs/server.log" URL_FILE="$FRESHELL_HOME/url" PID_FILE="$FRESHELL_HOME/server.pid" cd "$FRESHELL_DIR" -# Check for already-running server (verify PID is actually a node process from this project) +is_freshell_pid() { + local pid="$1" + local cwd="" + local args="" + cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null || true)" + args="$(ps -p "$pid" -o args= 2>/dev/null || true)" + [[ "$cwd" == "$FRESHELL_DIR" && ( "$args" == *"dist/server/index.js"* || "$args" == *"npm start"* ) ]] +} + +# Check for already-running server (verify PID belongs to this project) if [[ -f "$PID_FILE" ]]; then saved_pid="$(cat "$PID_FILE")" - if kill -0 "$saved_pid" 2>/dev/null && ps -p "$saved_pid" -o args= 2>/dev/null | grep -q "dist/server/index.js"; then + if kill -0 "$saved_pid" 2>/dev/null && is_freshell_pid "$saved_pid"; then echo "freshell is already running (pid $saved_pid)" if [[ -f "$URL_FILE" ]]; then echo " $(cat "$URL_FILE")" @@ -25,30 +34,8 @@ if [[ -f "$PID_FILE" ]]; then fi fi -# Pull latest from upstream (only on main) -current_branch="$(git branch --show-current)" -if [[ "$current_branch" == "main" ]]; then - echo "Pulling latest from upstream..." - if git remote get-url upstream >/dev/null 2>&1; then - git fetch upstream - if git merge-base --is-ancestor upstream/main HEAD 2>/dev/null; then - echo " Already up to date." - elif git merge --ff-only upstream/main 2>/dev/null; then - echo " Merged upstream changes." - else - echo " Local main has diverged from upstream. Resetting to upstream/main..." - git reset --hard upstream/main - echo " Done." - fi - else - echo " No upstream remote, skipping pull." - fi - - echo "Pushing to origin..." - git push origin main 2>/dev/null && echo " Done." || echo " Push failed (non-fatal)." -else - echo "Warning: on branch '$current_branch', skipping upstream pull. Switch to main for auto-update." -fi +echo "Checking self-host branch..." +npx tsx scripts/selfhost-branch.ts validate-launch # Build echo "Building..." @@ -59,7 +46,7 @@ echo "Starting server..." mkdir -p "$(dirname "$LOG_FILE")" rm -f "$URL_FILE" -NODE_ENV=production node dist/server/index.js > "$LOG_FILE" 2>&1 & +npm start > "$LOG_FILE" 2>&1 & SERVER_PID=$! echo "$SERVER_PID" > "$PID_FILE" diff --git a/scripts/precheck.ts b/scripts/precheck.ts index f0240fa77..ea1c07ed8 100644 --- a/scripts/precheck.ts +++ b/scripts/precheck.ts @@ -10,6 +10,7 @@ */ import { readFileSync } from 'fs' +import { execFileSync } from 'child_process' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { createRequire } from 'module' @@ -30,6 +31,18 @@ function getPackageVersion(): string { } } +function getCurrentBranch(): string | undefined { + try { + return execFileSync('git', ['branch', '--show-current'], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() || undefined + } catch { + return undefined + } +} + /** * Check if node_modules is missing required dependencies from package.json. * Returns list of missing packages. @@ -182,7 +195,7 @@ async function checkVitePort(): Promise<PortCheckResult> { async function main(): Promise<void> { // 1. Check for updates first (before anything else can fail) - if (!shouldSkipUpdateCheck()) { + if (!shouldSkipUpdateCheck({ branch: getCurrentBranch() })) { const currentVersion = getPackageVersion() const updateResult = await runUpdateCheck(currentVersion) diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts index db3b31969..fd8af348a 100644 --- a/scripts/run-standard-tests.ts +++ b/scripts/run-standard-tests.ts @@ -108,9 +108,11 @@ function classifySuitePath(token: string): SuiteName | null { normalizedToken.startsWith('test/server/') || normalizedToken.startsWith('test/unit/server/') || normalizedToken.startsWith('test/integration/server/') + || normalizedToken.startsWith('test/integration/real/') || normalizedToken.includes('/test/server/') || normalizedToken.includes('/test/unit/server/') || normalizedToken.includes('/test/integration/server/') + || normalizedToken.includes('/test/integration/real/') || normalizedToken.endsWith('/test/integration/session-repair.test.ts') || normalizedToken.endsWith('/test/integration/session-search-e2e.test.ts') || normalizedToken.endsWith('/test/integration/extension-system.test.ts') diff --git a/scripts/selfhost-branch.ts b/scripts/selfhost-branch.ts new file mode 100644 index 000000000..199ae269e --- /dev/null +++ b/scripts/selfhost-branch.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env tsx + +import { execFile } from 'child_process' +import { promisify } from 'util' +import { pathToFileURL } from 'url' +import { classifySelfHostBranch, type SelfHostPolicyEnv } from '../shared/selfhost-branch-policy.js' + +const execFileAsync = promisify(execFile) + +export type LaunchBranchValidation = + | { ok: true; branch: string } + | { ok: false; message: string } + +export async function getCurrentGitBranch(cwd: string = process.cwd()): Promise<string | undefined> { + try { + const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd }) + return stdout.trim() || undefined + } catch { + return undefined + } +} + +export async function validateLaunchBranch(input: { + env: SelfHostPolicyEnv + getBranch?: () => Promise<string | undefined> +}): Promise<LaunchBranchValidation> { + const branch = await (input.getBranch ?? (() => getCurrentGitBranch()))() + const result = classifySelfHostBranch({ branch, env: input.env }) + if (result.ok === true) return { ok: true, branch: branch ?? result.expectedBranch } + return { ok: false, message: result.message } +} + +async function main(argv: string[]): Promise<number> { + const command = argv[0] + if (command !== 'validate-launch') { + console.error('Usage: tsx scripts/selfhost-branch.ts validate-launch') + return 2 + } + + const result = await validateLaunchBranch({ env: process.env }) + if (result.ok === false) { + console.error(result.message) + return 1 + } + + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then((code) => process.exit(code)) +} diff --git a/server/agent-api/layout-schema.ts b/server/agent-api/layout-schema.ts index 696b2048f..5c72e7477 100644 --- a/server/agent-api/layout-schema.ts +++ b/server/agent-api/layout-schema.ts @@ -1,8 +1,88 @@ import { z } from 'zod' import { SessionLocatorSchema } from '../../shared/ws-protocol.js' +const FreshAgentContentSchema = z.object({ + kind: z.literal('fresh-agent'), + sessionType: z.string().min(1), + provider: z.string().min(1), + createRequestId: z.string().min(1), + status: z.string().min(1), + sessionId: z.string().optional(), + resumeSessionId: z.string().optional(), + sessionRef: z.object({ provider: z.string().min(1), sessionId: z.string().min(1) }).optional(), + restoreError: z.object({ code: z.string().min(1), reason: z.string().min(1) }).optional(), + initialCwd: z.string().optional(), + model: z.string().optional(), + modelSelection: z.object({ kind: z.string().min(1), modelId: z.string().min(1) }).optional().or(z.null()), + permissionMode: z.string().optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + effort: z.string().optional(), + plugins: z.array(z.string()).optional(), + settingsDismissed: z.boolean().optional(), +}).passthrough().refine( + (v) => !(v.sessionRef && v.restoreError), + { message: 'sessionRef and restoreError are mutually exclusive' }, +) + const PaneNodeSchema: z.ZodType<any> = z.lazy(() => z.union([ - z.object({ type: z.literal('leaf'), id: z.string(), content: z.record(z.string(), z.any()) }), + z.object({ + type: z.literal('leaf'), + id: z.string(), + content: z.union([ + z.object({ + kind: z.literal('terminal'), + createRequestId: z.string(), + status: z.string(), + mode: z.string(), + terminalId: z.string().optional(), + shell: z.string().optional(), + resumeSessionId: z.string().optional(), + sessionRef: SessionLocatorSchema.optional(), + restoreError: z.object({ code: z.string(), reason: z.string() }).optional(), + initialCwd: z.string().optional(), + }).passthrough(), + z.object({ + kind: z.literal('browser'), + browserInstanceId: z.string(), + url: z.string(), + devToolsOpen: z.boolean(), + }).passthrough(), + z.object({ + kind: z.literal('editor'), + filePath: z.string().nullable(), + language: z.string().nullable(), + readOnly: z.boolean(), + content: z.string(), + viewMode: z.enum(['source', 'preview']), + }).passthrough(), + z.object({ + kind: z.literal('picker'), + }).passthrough(), + FreshAgentContentSchema, + z.object({ + kind: z.literal('agent-chat'), + provider: z.string(), + createRequestId: z.string(), + status: z.string(), + sessionId: z.string().optional(), + resumeSessionId: z.string().optional(), + sessionRef: SessionLocatorSchema.optional(), + restoreError: z.object({ code: z.string(), reason: z.string() }).optional(), + initialCwd: z.string().optional(), + modelSelection: z.object({ kind: z.string(), modelId: z.string() }).optional().or(z.null()), + permissionMode: z.string().optional(), + effort: z.string().optional(), + plugins: z.array(z.string()).optional(), + settingsDismissed: z.boolean().optional(), + }).passthrough(), + z.object({ + kind: z.literal('extension'), + extensionName: z.string(), + props: z.record(z.string(), z.any()), + }).passthrough(), + z.object({ kind: z.string() }).passthrough(), + ]), + }), z.object({ type: z.literal('split'), id: z.string(), diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts index 9803fb63a..915acfa68 100644 --- a/server/agent-api/layout-store.ts +++ b/server/agent-api/layout-store.ts @@ -100,6 +100,19 @@ export class LayoutStore { } } + if (content.kind === 'fresh-agent') { + switch (content.sessionType) { + case 'freshclaude': + return 'Freshclaude' + case 'freshcodex': + return 'Freshcodex' + case 'kilroy': + return 'Kilroy' + default: + return 'Fresh Agent' + } + } + if (content.kind === 'extension') { return typeof content.extensionName === 'string' && content.extensionName ? content.extensionName @@ -145,6 +158,12 @@ export class LayoutStore { updateFromUi(snapshot: UiSnapshot, connectionId: string) { this.snapshot = snapshot this.sourceConnectionId = connectionId + for (const tab of snapshot.tabs) { + const leaves = this.collectLeaves(snapshot.layouts?.[tab.id], []) + for (const leaf of leaves) { + this.seedPaneTitle(tab.id, leaf.id, leaf.content) + } + } } getSourceConnectionId() { @@ -311,6 +330,17 @@ export class LayoutStore { return undefined } + findPaneByTerminalId(terminalId: string): { tabId: string; paneId: string } | undefined { + if (!this.snapshot) return undefined + for (const tab of this.snapshot.tabs) { + const root = this.snapshot.layouts?.[tab.id] + const leaves = this.collectLeaves(root, []) + const match = leaves.find((leaf) => leaf.content?.terminalId === terminalId) + if (match) return { tabId: tab.id, paneId: match.id } + } + return undefined + } + getPaneSnapshot(paneId: string): PaneSnapshot | undefined { if (!this.snapshot) return undefined for (const tab of this.snapshot.tabs) { diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index b2fb02db4..82a4d784f 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -4,24 +4,39 @@ import { randomUUID } from 'node:crypto' import { nanoid } from 'nanoid' import { allocateLocalhostPort } from '../local-port.js' import type { CodexLaunchPlan, CodexLaunchPlanner } from '../coding-cli/codex-app-server/launch-planner.js' +import { + planCodexLaunchWithRetry, +} from '../coding-cli/codex-app-server/launch-retry.js' import { CodexLaunchConfigError, getCodexSessionBindingReason, normalizeCodexSandboxSetting, } from '../coding-cli/codex-launch-config.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' import { makeSessionKey } from '../coding-cli/types.js' -import { terminalIdFromCreateError, type ProviderSettings } from '../terminal-registry.js' +import { terminalIdFromCreateError, type ProviderSettings, type TerminalInputResult } from '../terminal-registry.js' import { MAX_TERMINAL_TITLE_OVERRIDE_LENGTH } from '../terminals-router.js' +import { logger } from '../logger.js' import { ok, approx, fail } from './response.js' import { renderCapture } from './capture.js' import { waitForMatch } from './wait-for.js' import { resolveScreenshotOutputPath } from './screenshot-path.js' +import { sanitizeSessionRef } from '../../shared/session-contract.js' const truthy = (value: unknown) => value === true || value === 'true' || value === '1' || value === 'yes' const SYNCABLE_TERMINAL_MODES = new Set(['claude', 'codex', 'opencode', 'gemini', 'kimi']) +const log = logger.child({ component: 'agent-api' }) +const CODEX_INPUT_READY_WAIT_TIMEOUT_MS = 60_000 + +class AgentRouteInputError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentRouteInputError' + } +} function agentRouteErrorStatus(error: unknown): number { - return error instanceof CodexLaunchConfigError ? 400 : 500 + return error instanceof CodexLaunchConfigError || error instanceof AgentRouteInputError ? 400 : 500 } function errorMessage(error: unknown): string { @@ -71,15 +86,19 @@ async function resolveSpawnProviderSettings( throw new Error('Codex terminal launch requires the app-server launch planner.') } opts.assertTerminalCreateAccepted?.() - const plan = await opts.codexLaunchPlanner.planCreate({ - cwd: opts.cwd, - resumeSessionId: opts.resumeSessionId, - model: providerSettings?.model, - sandbox: normalizeCodexSandboxSetting(providerSettings?.sandbox), - approvalPolicy: providerSettings?.permissionMode, + const plan = await planCodexLaunchWithRetry({ + planner: opts.codexLaunchPlanner, + logger: log, + input: { + cwd: opts.cwd, + resumeSessionId: opts.resumeSessionId, + model: providerSettings?.model, + sandbox: normalizeCodexSandboxSetting(providerSettings?.sandbox), + approvalPolicy: providerSettings?.permissionMode, + }, }) return { - resumeSessionId: plan.sessionId, + resumeSessionId: opts.resumeSessionId ? (plan.sessionId ?? opts.resumeSessionId) : undefined, providerSettings: { codexAppServer: { ...plan.remote, @@ -116,6 +135,34 @@ async function resolveSpawnProviderSettings( type ResolvedSpawnProviderSettings = Awaited<ReturnType<typeof resolveSpawnProviderSettings>> +function requestedResumeSessionIdForMode( + sessionRef: ReturnType<typeof sanitizeSessionRef>, + mode: string, + legacyResumeSessionId: unknown, +): string | undefined { + const acceptedSessionRef = acceptedSessionRefForMode(sessionRef, mode) + if (acceptedSessionRef) return acceptedSessionRef.sessionId + if (mode === 'codex') { + if (isNonEmptyString(legacyResumeSessionId)) { + throw new AgentRouteInputError(INVALID_RAW_CODEX_RESUME_MESSAGE) + } + return undefined + } + return typeof legacyResumeSessionId === 'string' ? legacyResumeSessionId : undefined +} + +function acceptedSessionRefForMode( + sessionRef: ReturnType<typeof sanitizeSessionRef>, + mode: string, +): ReturnType<typeof sanitizeSessionRef> { + if (!sessionRef || sessionRef.provider !== mode) return undefined + return sessionRef +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + async function adoptCodexLaunch( launch: ResolvedSpawnProviderSettings | undefined, terminalId: string, @@ -127,14 +174,6 @@ async function cleanupUnadoptedCodexLaunch(launch: ResolvedSpawnProviderSettings await launch?.codexPlan?.sidecar.shutdown() } -async function waitForCodexResumeReadiness( - launch: ResolvedSpawnProviderSettings | undefined, - requestedResumeSessionId: string | undefined, -): Promise<void> { - if (!launch?.codexPlan || !requestedResumeSessionId) return - await launch.codexPlan.sidecar.waitForLoadedThread(requestedResumeSessionId) -} - function publishCodexLaunch(registry: any, launch: ResolvedSpawnProviderSettings | undefined, terminalId: string): void { if (!launch?.codexPlan) return registry.publishCodexSidecar?.(terminalId) @@ -146,6 +185,85 @@ function assertCodexCreateTerminalRunning(terminal: { status?: unknown }): void } } +function terminalInputFailureMessage(result: Exclude<TerminalInputResult, { status: 'written' }>): string { + if (result.status === 'blocked_codex_identity_pending') { + return 'Codex restore identity is not ready yet.' + } + if (result.status === 'blocked_codex_identity_capture_timeout') { + return 'Codex restore identity timed out before input could be accepted.' + } + if (result.status === 'blocked_codex_identity_unavailable') { + return 'Codex restore identity could not be captured before input could be accepted.' + } + if (result.status === 'blocked_codex_recovery_pending') { + return 'Codex durable recovery is still in progress.' + } + return 'Terminal is not running.' +} + +function shouldWaitForCodexIdentity(payload: Record<string, unknown>): boolean { + return truthy(payload.waitForCodexIdentity) +} + +function registrySupportsEvents(registry: any): registry is { + input: (terminalId: string, data: string) => TerminalInputResult + on: (event: string, handler: (...args: any[]) => void) => void + off: (event: string, handler: (...args: any[]) => void) => void +} { + return typeof registry?.on === 'function' && typeof registry?.off === 'function' +} + +async function sendTerminalInput( + registry: any, + terminalId: string, + data: string, + options: { waitForCodexIdentity?: boolean } = {}, +): Promise<TerminalInputResult> { + const first = registry.input(terminalId, data) as TerminalInputResult + if (first.status !== 'blocked_codex_identity_pending' || !options.waitForCodexIdentity) { + return first + } + if (!registrySupportsEvents(registry)) return first + + return new Promise<TerminalInputResult>((resolve) => { + let settled = false + let timeout: ReturnType<typeof setTimeout> | undefined + + const cleanup = () => { + if (timeout) clearTimeout(timeout) + registry.off('terminal.codex.durability.updated', onDurabilityUpdated) + registry.off('terminal.exit', onTerminalExit) + } + const finish = (result: TerminalInputResult) => { + if (settled) return + settled = true + cleanup() + resolve(result) + } + const retry = () => { + if (settled) return + const next = registry.input(terminalId, data) as TerminalInputResult + if (next.status === 'blocked_codex_identity_pending') return + finish(next) + } + const onDurabilityUpdated = (event: { terminalId?: string }) => { + if (event?.terminalId !== terminalId) return + retry() + } + const onTerminalExit = (event: { terminalId?: string }) => { + if (event?.terminalId !== terminalId) return + finish({ status: 'not_running' }) + } + + registry.on('terminal.codex.durability.updated', onDurabilityUpdated) + registry.on('terminal.exit', onTerminalExit) + timeout = setTimeout(() => { + finish({ status: 'blocked_codex_identity_capture_timeout', terminalId }) + }, CODEX_INPUT_READY_WAIT_TIMEOUT_MS) + queueMicrotask(retry) + }) +} + async function cleanupCreatedTerminal(registry: any, terminalId: string | undefined): Promise<void> { if (!terminalId) return if (typeof registry?.killAndWait === 'function') { @@ -358,10 +476,12 @@ export function createAgentApiRouter({ router.post('/tabs', async (req, res) => { const { name, mode, shell, cwd, browser, editor, resumeSessionId, permissionMode, model, sandbox } = req.body || {} + const requestedSessionRef = sanitizeSessionRef(req.body?.sessionRef) const wantsBrowser = !!browser const wantsEditor = !!editor let launch: ResolvedSpawnProviderSettings | undefined let createdTerminalId: string | undefined + let createdTabId: string | undefined try { let paneContent: any @@ -373,16 +493,23 @@ export function createAgentApiRouter({ paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source' } } else { const effectiveMode = mode || 'shell' + const acceptedSessionRef = acceptedSessionRefForMode(requestedSessionRef, effectiveMode) + const requestedResumeSessionId = requestedResumeSessionIdForMode( + requestedSessionRef, + effectiveMode, + resumeSessionId, + ) assertTerminalAdmission() launch = await resolveSpawnProviderSettings( effectiveMode, configStore, { permissionMode, model, sandbox }, - { cwd, resumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission }, + { cwd, resumeSessionId: requestedResumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission }, ) assertTerminalAdmission() const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor }) - const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, resumeSessionId) + createdTabId = tabId + const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, requestedResumeSessionId) assertTerminalAdmission() const terminal = registry.create({ mode: effectiveMode, @@ -397,8 +524,6 @@ export function createAgentApiRouter({ const launchResumeSessionId = launch.resumeSessionId assertTerminalAdmission() await adoptCodexLaunch(launch, terminal.terminalId) - assertTerminalAdmission() - await waitForCodexResumeReadiness(launch, resumeSessionId) assertCodexCreateTerminalRunning(terminal) assertTerminalAdmission() publishCodexLaunch(registry, launch, terminal.terminalId) @@ -410,7 +535,8 @@ export function createAgentApiRouter({ status: 'running', mode: mode || 'shell', shell: shell || 'system', - resumeSessionId: launchResumeSessionId, + ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}), + ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}), initialCwd: cwd, } @@ -425,7 +551,8 @@ export function createAgentApiRouter({ shell, terminalId, initialCwd: cwd, - resumeSessionId: paneContent?.resumeSessionId, + ...(paneContent?.resumeSessionId ? { resumeSessionId: paneContent.resumeSessionId } : {}), + ...(paneContent?.sessionRef ? { sessionRef: paneContent.sessionRef } : {}), paneId, paneContent, }, @@ -437,6 +564,7 @@ export function createAgentApiRouter({ } const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor }) + createdTabId = tabId layoutStore.attachPaneContent(tabId, paneId, paneContent) wsHandler?.broadcastUiCommand({ @@ -449,6 +577,7 @@ export function createAgentApiRouter({ terminalId, initialCwd: cwd, resumeSessionId: paneContent?.resumeSessionId, + sessionRef: paneContent?.sessionRef, paneId, paneContent, }, @@ -460,6 +589,13 @@ export function createAgentApiRouter({ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => { responseError = combineWithCleanupError(err, cleanupError) }) + if (createdTabId && typeof layoutStore.closeTab === 'function') { + try { + layoutStore.closeTab(createdTabId) + } catch { + // best-effort cleanup; terminal/sidecar cleanup errors above remain authoritative + } + } const status = agentRouteErrorStatus(responseError) res.status(status).json(fail(responseError?.message || 'Failed to create tab')) } @@ -746,6 +882,7 @@ export function createAgentApiRouter({ const timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 30000 let launch: ResolvedSpawnProviderSettings | undefined let createdTerminalId: string | undefined + let createdTabId: string | undefined try { assertTerminalAdmission() launch = await resolveSpawnProviderSettings(mode, configStore, {}, { @@ -757,6 +894,7 @@ export function createAgentApiRouter({ const created = layoutStore.createTab?.({ title }) const tabId = created?.tabId || nanoid() const paneId = created?.paneId || nanoid() + createdTabId = created?.tabId const sessionBindingReason = getCodexSessionBindingReason(mode) assertTerminalAdmission() const terminal = registry.create({ @@ -782,7 +920,12 @@ export function createAgentApiRouter({ const sentinel = `__FRESHELL_DONE_${nanoid()}__` const input = capture ? `${command}; echo ${sentinel}\r` : `${command}\r` - registry.input(terminal.terminalId, input) + const inputResult = await sendTerminalInput(registry, terminal.terminalId, input, { + waitForCodexIdentity: mode === 'codex', + }) + if (inputResult.status !== 'written') { + throw new Error(terminalInputFailureMessage(inputResult)) + } if (!capture || detached) { const message = detached ? 'command started (detached)' : 'command sent' @@ -807,6 +950,13 @@ export function createAgentApiRouter({ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => { responseError = combineWithCleanupError(err, cleanupError) }) + if (createdTabId && typeof layoutStore.closeTab === 'function') { + try { + layoutStore.closeTab(createdTabId) + } catch { + // best-effort cleanup; terminal/sidecar cleanup errors above remain authoritative + } + } const status = agentRouteErrorStatus(responseError) return res.status(status).json(fail(responseError?.message || 'Failed to run command')) } @@ -823,6 +973,18 @@ export function createAgentApiRouter({ const direction = req.body?.direction || 'vertical' const wantsBrowser = !!req.body?.browser const wantsEditor = !!req.body?.editor + const splitMode = !wantsBrowser && !wantsEditor ? req.body?.mode || 'shell' : undefined + const requestedSessionRef = splitMode ? sanitizeSessionRef(req.body?.sessionRef) : undefined + const acceptedSessionRef = splitMode + ? acceptedSessionRefForMode(requestedSessionRef, splitMode) + : undefined + const requestedResumeSessionId = splitMode + ? requestedResumeSessionIdForMode( + requestedSessionRef, + splitMode, + req.body?.resumeSessionId, + ) + : undefined if (!wantsBrowser && !wantsEditor) { assertTerminalAdmission() } @@ -849,23 +1011,23 @@ export function createAgentApiRouter({ } else if (wantsEditor) { content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source' } } else { - const splitMode = req.body?.mode || 'shell' + const terminalMode = splitMode ?? 'shell' launch = await resolveSpawnProviderSettings( - splitMode, + terminalMode, configStore, {}, { cwd: req.body?.cwd, - resumeSessionId: req.body?.resumeSessionId, + resumeSessionId: requestedResumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission, }, ) assertTerminalAdmission() - const sessionBindingReason = getCodexSessionBindingReason(splitMode, req.body?.resumeSessionId) + const sessionBindingReason = getCodexSessionBindingReason(terminalMode, requestedResumeSessionId) assertTerminalAdmission() const terminal = registry.create({ - mode: splitMode, + mode: terminalMode, shell: req.body?.shell, cwd: req.body?.cwd, resumeSessionId: launch.resumeSessionId, @@ -877,8 +1039,6 @@ export function createAgentApiRouter({ const launchResumeSessionId = launch.resumeSessionId assertTerminalAdmission() await adoptCodexLaunch(launch, terminal.terminalId) - assertTerminalAdmission() - await waitForCodexResumeReadiness(launch, req.body?.resumeSessionId) assertCodexCreateTerminalRunning(terminal) assertTerminalAdmission() publishCodexLaunch(registry, launch, terminal.terminalId) @@ -890,7 +1050,8 @@ export function createAgentApiRouter({ status: 'running', mode: req.body?.mode || 'shell', shell: req.body?.shell || 'system', - ...(launchResumeSessionId ? { resumeSessionId: launchResumeSessionId } : {}), + ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}), + ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}), } } @@ -1080,6 +1241,13 @@ export function createAgentApiRouter({ const tabId = target?.tabId if (!tabId) return res.status(404).json(fail('pane not found')) const effectiveMode = req.body?.mode || 'shell' + const requestedSessionRef = sanitizeSessionRef(req.body?.sessionRef) + const acceptedSessionRef = acceptedSessionRefForMode(requestedSessionRef, effectiveMode) + const requestedResumeSessionId = requestedResumeSessionIdForMode( + requestedSessionRef, + effectiveMode, + req.body?.resumeSessionId, + ) assertTerminalAdmission() launch = await resolveSpawnProviderSettings( effectiveMode, @@ -1087,13 +1255,13 @@ export function createAgentApiRouter({ {}, { cwd: req.body?.cwd, - resumeSessionId: req.body?.resumeSessionId, + resumeSessionId: requestedResumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission, }, ) assertTerminalAdmission() - const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, req.body?.resumeSessionId) + const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, requestedResumeSessionId) assertTerminalAdmission() const terminal = registry.create({ mode: effectiveMode, @@ -1108,8 +1276,6 @@ export function createAgentApiRouter({ const launchResumeSessionId = launch.resumeSessionId assertTerminalAdmission() await adoptCodexLaunch(launch, terminal.terminalId) - assertTerminalAdmission() - await waitForCodexResumeReadiness(launch, req.body?.resumeSessionId) assertCodexCreateTerminalRunning(terminal) assertTerminalAdmission() publishCodexLaunch(registry, launch, terminal.terminalId) @@ -1121,7 +1287,8 @@ export function createAgentApiRouter({ mode: req.body?.mode || 'shell', shell: req.body?.shell || 'system', createRequestId: nanoid(), - ...(launchResumeSessionId ? { resumeSessionId: launchResumeSessionId } : {}), + ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}), + ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}), } layoutStore.attachPaneContent(tabId, paneId, content) wsHandler?.broadcastUiCommand({ command: 'pane.attach', payload: { tabId, paneId, content } }) @@ -1174,7 +1341,7 @@ export function createAgentApiRouter({ res.json(ok(undefined, 'navigate requested')) }) - router.post('/panes/:id/send-keys', (req, res) => { + router.post('/panes/:id/send-keys', async (req, res) => { const resolved = resolvePaneTarget(req.params.id) if (rejectPaneTargetError(res, resolved)) return const paneId = resolved.paneId || req.params.id @@ -1186,8 +1353,13 @@ export function createAgentApiRouter({ if (target?.paneId) terminalId = layoutStore.resolvePaneToTerminal?.(target.paneId) } if (!terminalId) return res.status(404).json(fail('terminal not found')) - const okInput = registry.input(terminalId, data) - res.json(ok({ terminalId }, okInput ? 'input sent' : 'terminal not running')) + const inputResult = await sendTerminalInput(registry, terminalId, data, { + waitForCodexIdentity: shouldWaitForCodexIdentity(payload), + }) + if (inputResult.status !== 'written') { + return res.status(409).json(fail(terminalInputFailureMessage(inputResult))) + } + res.json(ok({ terminalId }, 'input sent')) }) return router diff --git a/server/agent-timeline/service.ts b/server/agent-timeline/service.ts index fcb849b81..d670f88ae 100644 --- a/server/agent-timeline/service.ts +++ b/server/agent-timeline/service.ts @@ -20,7 +20,15 @@ type TimelineCursorPayload = { type TimelineMessageRecord = CanonicalTurn & { sessionId: string } +export type AgentTimelineSnapshot = { + sessionId: string + latestTurnId: string | null + revision: number + turns: CanonicalTurn[] +} + export type AgentTimelineService = { + getSnapshot: (query: { sessionId: string; revision?: number; signal?: AbortSignal }) => Promise<AgentTimelineSnapshot> getTimelinePage: (query: AgentTimelinePageQuery & { sessionId: string; signal?: AbortSignal }) => Promise<AgentTimelinePage> getTurnBody: (query: AgentTimelineTurnBodyQuery & { sessionId: string; turnId: string; signal?: AbortSignal }) => Promise<AgentTimelineTurn | null> } @@ -134,6 +142,30 @@ export function createAgentTimelineService(deps: AgentTimelineServiceDeps): Agen } return { + async getSnapshot({ sessionId, revision, signal }) { + throwIfAborted(signal) + const timeline = await loadTimeline(sessionId) + throwIfAborted(signal) + if (revision != null && revision !== timeline.revision) { + throw new RestoreStaleRevisionError(revision, timeline.revision) + } + return { + sessionId: timeline.sessionId, + latestTurnId: timeline.latestTurnId, + revision: timeline.revision, + turns: timeline.records + .slice() + .reverse() + .map((record) => ({ + turnId: record.turnId, + messageId: record.messageId, + ordinal: record.ordinal, + source: record.source, + message: record.message, + })), + } + }, + async getTimelinePage(query) { throwIfAborted(query.signal) if (query.revision == null) { diff --git a/server/claude-session-id.ts b/server/claude-session-id.ts index 5cb6efb6a..b6616834b 100644 --- a/server/claude-session-id.ts +++ b/server/claude-session-id.ts @@ -1,5 +1,5 @@ -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +import { isCanonicalClaudeSessionId } from '../shared/session-contract.js' export function isValidClaudeSessionId(value?: string): value is string { - return typeof value === 'string' && UUID_REGEX.test(value) + return isCanonicalClaudeSessionId(value) } diff --git a/server/cli/index.ts b/server/cli/index.ts index 6a0a8b346..248caf75a 100644 --- a/server/cli/index.ts +++ b/server/cli/index.ts @@ -7,6 +7,7 @@ import { resolveConfig } from './config.js' import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' type Flags = Record<string, string | boolean> @@ -106,6 +107,45 @@ const isTruthy = (value: unknown) => value === true || value === 'true' || value const unwrap = (response: any) => (response && typeof response === 'object' && 'data' in response ? response.data : response) +function rejectRawCodexResume(mode: unknown, resumeSessionId: unknown): boolean { + if (mode === 'codex' && typeof resumeSessionId === 'string' && resumeSessionId.length > 0) { + writeError(INVALID_RAW_CODEX_RESUME_MESSAGE) + process.exitCode = 1 + return true + } + return false +} + +type CliSessionRef = { provider: string; sessionId: string } + +function resolveSessionRefFlag(mode: unknown, raw: unknown): { rejected: boolean; sessionRef?: CliSessionRef } { + if (raw === undefined) return { rejected: false } + if (typeof raw !== 'string' || raw.trim().length === 0) { + writeError('--session-ref must use provider:sessionId syntax.') + process.exitCode = 1 + return { rejected: true } + } + const separator = raw.indexOf(':') + if (separator <= 0 || separator === raw.length - 1) { + writeError('--session-ref must use provider:sessionId syntax.') + process.exitCode = 1 + return { rejected: true } + } + const provider = raw.slice(0, separator).trim() + const sessionId = raw.slice(separator + 1).trim() + if (!provider || !sessionId) { + writeError('--session-ref must use provider:sessionId syntax.') + process.exitCode = 1 + return { rejected: true } + } + if (typeof mode !== 'string' || mode !== provider) { + writeError('--session-ref provider must match --mode.') + process.exitCode = 1 + return { rejected: true } + } + return { rejected: false, sessionRef: { provider, sessionId } } +} + async function fetchTabs(client: ReturnType<typeof createHttpClient>): Promise<{ tabs: TabSummary[]; activeTabId?: string | null }> { const res = await client.get('/api/tabs') const data = unwrap(res) @@ -311,12 +351,27 @@ async function main() { const browser = getFlag(flags, 'browser') as string | undefined const editor = getFlag(flags, 'editor') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined + const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) const prompt = getFlag(flags, 'prompt') as string | undefined + if (rejectRawCodexResume(mode, resumeSessionId)) return + if (sessionRefResult.rejected) return - const res = await client.post('/api/tabs', { name, mode, shell, cwd, browser, editor, resumeSessionId }) + const res = await client.post('/api/tabs', { + name, + mode, + shell, + cwd, + browser, + editor, + resumeSessionId, + ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}), + }) const data = unwrap(res) if (prompt && data?.paneId) { - await client.post(`/api/panes/${encodeURIComponent(data.paneId)}/send-keys`, { data: `${prompt}\r` }) + await client.post(`/api/panes/${encodeURIComponent(data.paneId)}/send-keys`, { + data: `${prompt}\r`, + ...(mode === 'codex' ? { waitForCodexIdentity: true } : {}), + }) } writeJson(res) return @@ -405,6 +460,10 @@ async function main() { const mode = getFlag(flags, 'mode') as string | undefined const shell = getFlag(flags, 'shell') as string | undefined const cwd = getFlag(flags, 'cwd') as string | undefined + const resumeSessionId = getFlag(flags, 'resume') as string | undefined + const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) + if (rejectRawCodexResume(mode, resumeSessionId)) return + if (sessionRefResult.rejected) return const resolved = await resolvePaneTarget(client, target) if (!resolved.pane?.id) { @@ -420,6 +479,8 @@ async function main() { mode, shell, cwd, + resumeSessionId, + ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}), }) writeJson(res) return @@ -536,13 +597,23 @@ async function main() { const mode = getFlag(flags, 'mode') as string | undefined const shell = getFlag(flags, 'shell') as string | undefined const cwd = getFlag(flags, 'cwd') as string | undefined + const resumeSessionId = getFlag(flags, 'resume') as string | undefined + const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) + if (rejectRawCodexResume(mode, resumeSessionId)) return + if (sessionRefResult.rejected) return const resolved = await resolvePaneTarget(client, target) if (!resolved.pane?.id) { writeError(resolved.message || 'pane not found') process.exitCode = 1 return } - const res = await client.post(`/api/panes/${encodeURIComponent(resolved.pane.id)}/respawn`, { mode, shell, cwd }) + const res = await client.post(`/api/panes/${encodeURIComponent(resolved.pane.id)}/respawn`, { + mode, + shell, + cwd, + resumeSessionId, + ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}), + }) writeJson(res) return } diff --git a/server/coding-cli/codex-app-server/client.ts b/server/coding-cli/codex-app-server/client.ts index c0154263d..e081cd003 100644 --- a/server/coding-cli/codex-app-server/client.ts +++ b/server/coding-cli/codex-app-server/client.ts @@ -13,12 +13,37 @@ import { CodexThreadLifecycleNotificationSchema, CodexThreadStartedNotificationSchema, CodexThreadOperationResultSchema, + CodexThreadPageParamsSchema, + CodexThreadForkParamsSchema, + CodexThreadReadParamsSchema, + CodexThreadReadResultSchema, + CodexThreadResumeParamsSchema, + CodexThreadSchema, + CodexThreadStartParamsSchema, + CodexThreadTurnReadResultSchema, + CodexThreadTurnsListResultSchema, + CodexTurnInterruptParamsSchema, + CodexTurnInterruptResultSchema, + CodexTurnStartParamsSchema, + CodexTurnStartResultSchema, + CodexTurnCompletedNotificationSchema, + CodexTurnStartedNotificationSchema, type CodexInitializeResult, + type CodexRequestId, type CodexRpcError, type CodexThreadHandle, type CodexThreadOperationResult, + type CodexThreadReadParams, + type CodexThreadReadResult, + type CodexThreadForkParams, type CodexThreadResumeParams, type CodexThreadStartParams, + type CodexThreadTurnReadParams, + type CodexThreadTurnReadResult, + type CodexThreadTurnsListParams, + type CodexThreadTurnsListResult, + type CodexTurnInterruptParams, + type CodexTurnStartParams, } from './protocol.js' type CodexAppServerClientOptions = { @@ -36,13 +61,24 @@ type PendingRequest = { timeout: NodeJS.Timeout } +const DEFAULT_REQUEST_TIMEOUT_MS = 5_000 +const LOSS_STATUSES = new Set(['notLoaded', 'systemError']) + +type CodexThreadStartInput = + Omit<CodexThreadStartParams, 'experimentalRawEvents' | 'persistExtendedHistory'> & { + richClient?: boolean + } + +type CodexThreadResumeInput = Omit<CodexThreadResumeParams, 'persistExtendedHistory'> + +type CodexThreadOperationClientResult = CodexThreadOperationResult & { + threadId: string +} + export type CodexThreadLifecycleLossEvent = | { method: 'thread/closed'; threadId?: string } | { method: 'thread/status/changed'; threadId?: string; status: 'notLoaded' | 'systemError' } -const DEFAULT_REQUEST_TIMEOUT_MS = 5_000 -const LOSS_STATUSES = new Set(['notLoaded', 'systemError']) - export type CodexThreadLifecycleEvent = { kind: 'thread_started' thread: CodexThreadHandle @@ -60,12 +96,14 @@ export type CodexAppServerDisconnectEvent = { error?: Error } -function normalizeThread(thread: CodexThreadHandle): CodexThreadHandle { - return { - ...thread, - path: thread.path ?? null, - ephemeral: thread.ephemeral ?? false, - } +export type CodexTurnEvent = { + threadId: string + turnId?: string + params: Record<string, unknown> +} + +function normalizeThread(thread: CodexThreadHandle): CodexThreadOperationResult['thread'] { + return CodexThreadSchema.parse(thread) } export class CodexAppServerClient { @@ -74,11 +112,13 @@ export class CodexAppServerClient { private connectPromise: Promise<WebSocket> | null = null private initializePromise: Promise<CodexInitializeResult> | null = null private nextRequestId = 1 - private pendingRequests = new Map<number, PendingRequest>() + private pendingRequests = new Map<CodexRequestId, PendingRequest>() private readonly threadStartedHandlers = new Set<(thread: CodexThreadHandle) => void>() private readonly threadLifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>() private readonly disconnectHandlers = new Set<(event: CodexAppServerDisconnectEvent) => void>() private readonly fsChangedHandlers = new Set<(event: { watchId: string; changedPaths: string[] }) => void>() + private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>() + private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>() private lifecycleLossHandlers = new Set<(event: CodexThreadLifecycleLossEvent) => void>() constructor( @@ -95,6 +135,7 @@ export class CodexAppServerClient { clientInfo: { name: 'freshell', version: '1.0.0' }, capabilities: { experimentalApi: true, + optOutNotificationMethods: ['thread/started'], }, })).then(async (result) => { const parsed = CodexInitializeResultSchema.safeParse(result) @@ -111,36 +152,43 @@ export class CodexAppServerClient { return this.initializePromise } - async startThread( - params: Omit<CodexThreadStartParams, 'experimentalRawEvents' | 'persistExtendedHistory'>, - ): Promise<{ threadId: string }> { - const result = await this.request('thread/start', { - ...params, - // Freshell attaches the visible TUI over `codex --remote`, so it does not - // need the app-server's raw event stream for fresh threads. - experimentalRawEvents: false, + async startThread(params: CodexThreadStartInput): Promise<CodexThreadOperationClientResult> { + const { richClient, ...appServerParams } = params + const result = await this.request('thread/start', CodexThreadStartParamsSchema.parse({ + ...appServerParams, + experimentalRawEvents: richClient === true, persistExtendedHistory: true, - }) + })) const parsed = CodexThreadOperationResultSchema.safeParse(result) if (!parsed.success) { throw new Error('Codex app-server returned an invalid thread/start payload.') } - return { threadId: parsed.data.thread.id } + const thread = normalizeThread(parsed.data.thread) + this.emitThreadStartedEvidence(thread) + return { + ...parsed.data, + thread, + threadId: thread.id, + } } - async resumeThread( - params: Omit<CodexThreadResumeParams, 'persistExtendedHistory'>, - ): Promise<{ threadId: string }> { + async resumeThread(params: CodexThreadResumeInput): Promise<CodexThreadOperationClientResult> { // Intentionally preserve Codex's default raw-event behavior for resume calls. - const result = await this.request('thread/resume', { + const result = await this.request('thread/resume', CodexThreadResumeParamsSchema.parse({ ...params, persistExtendedHistory: true, - }) + })) const parsed = CodexThreadOperationResultSchema.safeParse(result) if (!parsed.success) { throw new Error('Codex app-server returned an invalid thread/resume payload.') } - return { threadId: parsed.data.thread.id } + const thread = normalizeThread(parsed.data.thread) + this.emitThreadStartedEvidence(thread) + return { + ...parsed.data, + thread, + threadId: thread.id, + } } async watchPath(targetPath: string, watchId: string): Promise<{ path: string }> { @@ -170,6 +218,84 @@ export class CodexAppServerClient { return parsed.data.data } + async forkThread(params: CodexThreadForkParams): Promise<{ threadId: string }> { + const result = await this.request('thread/fork', CodexThreadForkParamsSchema.parse(params)) + const parsed = CodexThreadOperationResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid thread/fork payload.') + } + return { threadId: parsed.data.thread.id } + } + + async readThread(params: CodexThreadReadParams): Promise<CodexThreadReadResult> { + const result = await this.request('thread/read', CodexThreadReadParamsSchema.parse(params)) + const parsed = CodexThreadReadResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid thread/read payload.') + } + return parsed.data + } + + async listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResult> { + const parsedParams = CodexThreadPageParamsSchema.parse(params) + const result = await this.request('thread/read', { + threadId: parsedParams.threadId, + includeTurns: true, + }) + const parsedThread = CodexThreadReadResultSchema.safeParse(result) + if (!parsedThread.success) { + throw new Error('Codex app-server returned an invalid thread/read payload.') + } + const turns = parsedThread.data.thread.turns.slice(0, parsedParams.limit) + const parsed = CodexThreadTurnsListResultSchema.safeParse({ + revision: Math.max(0, Math.trunc(parsedThread.data.thread.updatedAt)), + nextCursor: null, + backwardsCursor: null, + turns, + bodies: Object.fromEntries(turns.map((turn) => [turn.id, turn])), + }) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid synthesized thread turn page.') + } + return parsed.data + } + + async readThreadTurn(params: CodexThreadTurnReadParams): Promise<CodexThreadTurnReadResult> { + const page = await this.listThreadTurns({ + threadId: params.threadId, + }) + const turn = page.turns.find((candidate) => candidate.id === params.turnId) + if (!turn) { + throw new Error(`Codex app-server thread ${params.threadId} does not contain turn ${params.turnId}.`) + } + const parsedTurn = CodexThreadTurnReadResultSchema.safeParse({ + ...turn, + turnId: turn.id, + revision: params.revision ?? page.revision, + }) + if (!parsedTurn.success) { + throw new Error('Codex app-server returned an invalid synthesized thread turn body.') + } + return parsedTurn.data + } + + async startTurn(params: CodexTurnStartParams): Promise<{ turnId: string }> { + const result = await this.request('turn/start', CodexTurnStartParamsSchema.parse(params)) + const parsed = CodexTurnStartResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid turn/start payload.') + } + return { turnId: parsed.data.turn.id } + } + + async interruptTurn(params: CodexTurnInterruptParams): Promise<void> { + const result = await this.request('turn/interrupt', CodexTurnInterruptParamsSchema.parse(params)) + const parsed = CodexTurnInterruptResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid turn/interrupt payload.') + } + } + async close(): Promise<void> { const socket = this.socket this.socket = null @@ -229,6 +355,20 @@ export class CodexAppServerClient { } } + onTurnStarted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnStartedHandlers.add(handler) + return () => { + this.turnStartedHandlers.delete(handler) + } + } + + onTurnCompleted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnCompletedHandlers.add(handler) + return () => { + this.turnCompletedHandlers.delete(handler) + } + } + onThreadLifecycleLoss(handler: (event: CodexThreadLifecycleLossEvent) => void): () => void { this.lifecycleLossHandlers.add(handler) return () => { @@ -316,6 +456,18 @@ export class CodexAppServerClient { return } + const turnStarted = CodexTurnStartedNotificationSchema.safeParse(notification.data) + if (turnStarted.success) { + this.emitTurnEvent(this.turnStartedHandlers, turnStarted.data.params) + return + } + + const turnCompleted = CodexTurnCompletedNotificationSchema.safeParse(notification.data) + if (turnCompleted.success) { + this.emitTurnEvent(this.turnCompletedHandlers, turnCompleted.data.params) + return + } + this.handleNotification(notification.data) return } @@ -372,6 +524,17 @@ export class CodexAppServerClient { } } + private emitTurnEvent(handlers: Set<(event: CodexTurnEvent) => void>, params: { threadId: string; turnId?: string } & Record<string, unknown>): void { + const event: CodexTurnEvent = { + threadId: params.threadId, + ...(typeof params.turnId === 'string' ? { turnId: params.turnId } : {}), + params, + } + for (const handler of handlers) { + handler(event) + } + } + private extractThreadId(params: unknown): string | undefined { if (!params || typeof params !== 'object') return undefined const object = params as Record<string, unknown> @@ -430,16 +593,7 @@ export class CodexAppServerClient { private emitThreadLifecycle(notification: import('./protocol.js').CodexThreadLifecycleNotification): void { if (notification.method === 'thread/started') { const thread = normalizeThread(notification.params.thread) - const event: CodexThreadLifecycleEvent = { - kind: 'thread_started', - thread, - } - for (const handler of this.threadLifecycleHandlers) { - handler(event) - } - for (const handler of this.threadStartedHandlers) { - handler(thread) - } + this.emitThreadStartedEvidence(thread) return } @@ -464,6 +618,19 @@ export class CodexAppServerClient { } } + private emitThreadStartedEvidence(thread: CodexThreadOperationResult['thread']): void { + const event: CodexThreadLifecycleEvent = { + kind: 'thread_started', + thread, + } + for (const handler of this.threadLifecycleHandlers) { + handler(event) + } + for (const handler of this.threadStartedHandlers) { + handler(thread) + } + } + private async request<TParams extends object>(method: string, params: TParams): Promise<unknown> { if (method !== 'initialize') { await (this.initializePromise ?? this.initialize()) @@ -484,7 +651,7 @@ export class CodexAppServerClient { timeout, }) - socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), (error) => { + socket.send(JSON.stringify({ id, method, params }), (error) => { if (!error) return clearTimeout(timeout) this.pendingRequests.delete(id) @@ -493,21 +660,10 @@ export class CodexAppServerClient { }) } - private async notify<TParams extends object>(method: string, params?: TParams): Promise<void> { + private async notify(method: string, params?: unknown): Promise<void> { const socket = await this.ensureSocket() - await new Promise<void>((resolve, reject) => { - socket.send(JSON.stringify({ - jsonrpc: '2.0', - method, - ...(params ? { params } : {}), - }), (error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) + const payload = params === undefined ? { method } : { method, params } + socket.send(JSON.stringify(payload)) } private formatRpcError(method: string, error: CodexRpcError): string { diff --git a/server/coding-cli/codex-app-server/durability-proof.ts b/server/coding-cli/codex-app-server/durability-proof.ts new file mode 100644 index 000000000..001986e20 --- /dev/null +++ b/server/coding-cli/codex-app-server/durability-proof.ts @@ -0,0 +1,142 @@ +import fsp from 'node:fs/promises' +import path from 'node:path' +import type { CodexRolloutProofFailureReason } from '../../../shared/codex-durability.js' + +type ProofFs = Pick<typeof fsp, 'open' | 'stat'> + +const FIRST_RECORD_CHUNK_BYTES = 8192 +const MAX_FIRST_RECORD_BYTES = 1024 * 1024 + +export type CodexRolloutProofSuccess = { + ok: true + candidateThreadId: string + rolloutPath: string + rolloutProofId: string +} + +export type CodexRolloutProofFailure = { + ok: false + reason: CodexRolloutProofFailureReason + message: string + candidateThreadId: string + rolloutPath: string +} + +export type CodexRolloutProofResult = CodexRolloutProofSuccess | CodexRolloutProofFailure + +export async function proofCodexRollout(input: { + rolloutPath: string + candidateThreadId: string + fsImpl?: ProofFs +}): Promise<CodexRolloutProofResult> { + const fsImpl = input.fsImpl ?? fsp + const rolloutPath = input.rolloutPath + const candidateThreadId = input.candidateThreadId + + const fail = (reason: CodexRolloutProofFailureReason, message: string): CodexRolloutProofFailure => ({ + ok: false, + reason, + message, + candidateThreadId, + rolloutPath, + }) + + if (!path.isAbsolute(rolloutPath)) { + return fail('invalid_path', 'Codex rollout proof path must be absolute.') + } + if (!candidateThreadId) { + return fail('mismatched_thread_id', 'Codex candidate thread id is empty.') + } + + let stat: Awaited<ReturnType<ProofFs['stat']>> + try { + stat = await fsImpl.stat(rolloutPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return fail('missing', 'Codex rollout proof file does not exist.') + } + return fail('read_error', `Could not stat Codex rollout proof file: ${errorMessage(error)}`) + } + + if (!stat.isFile()) { + return fail('not_regular_file', 'Codex rollout proof path is not a regular file.') + } + + let firstLine: string + try { + firstLine = (await readFirstLine(fsImpl, rolloutPath)).trim() + } catch (error) { + return fail('read_error', `Could not read Codex rollout proof file: ${errorMessage(error)}`) + } + + if (!firstLine) { + return fail('empty', 'Codex rollout proof file does not start with a JSONL record.') + } + + let firstRecord: unknown + try { + firstRecord = JSON.parse(firstLine) + } catch { + return fail('malformed_json', 'Codex rollout proof first JSONL record is malformed.') + } + + if (!firstRecord || typeof firstRecord !== 'object') { + return fail('malformed_json', 'Codex rollout proof first JSONL record is not an object.') + } + + const record = firstRecord as Record<string, unknown> + if (record.type !== 'session_meta') { + return fail('wrong_record_type', 'Codex rollout proof first JSONL record is not session_meta.') + } + + const payload = record.payload + const rolloutProofId = payload && typeof payload === 'object' + ? (payload as Record<string, unknown>).id + : undefined + if (typeof rolloutProofId !== 'string' || rolloutProofId.length === 0) { + return fail('missing_payload_id', 'Codex rollout proof session_meta payload.id is missing.') + } + + if (rolloutProofId !== candidateThreadId) { + return fail('mismatched_thread_id', 'Codex rollout proof id does not match candidate thread id.') + } + + return { + ok: true, + candidateThreadId, + rolloutPath, + rolloutProofId, + } +} + +async function readFirstLine(fsImpl: ProofFs, filePath: string): Promise<string> { + const handle = await fsImpl.open(filePath, 'r') + const chunks: Buffer[] = [] + let bytesSeen = 0 + + try { + while (bytesSeen < MAX_FIRST_RECORD_BYTES) { + const buffer = Buffer.alloc(Math.min(FIRST_RECORD_CHUNK_BYTES, MAX_FIRST_RECORD_BYTES - bytesSeen)) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, bytesSeen) + if (bytesRead === 0) break + + const slice = buffer.subarray(0, bytesRead) + const newlineIndex = slice.indexOf(10) + if (newlineIndex >= 0) { + chunks.push(slice.subarray(0, newlineIndex)) + return Buffer.concat(chunks).toString('utf8').replace(/\r$/, '') + } + + chunks.push(slice) + bytesSeen += bytesRead + } + } finally { + await handle.close() + } + + return Buffer.concat(chunks).toString('utf8').replace(/\r$/, '') +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/server/coding-cli/codex-app-server/durability-store.ts b/server/coding-cli/codex-app-server/durability-store.ts new file mode 100644 index 000000000..f7fe8c03b --- /dev/null +++ b/server/coding-cli/codex-app-server/durability-store.ts @@ -0,0 +1,123 @@ +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + CodexDurabilityStoreRecordSchema, + type CodexDurabilityStoreRecord, +} from '../../../shared/codex-durability.js' + +type StoreFs = Pick<typeof fsp, 'mkdir' | 'readdir' | 'readFile' | 'rename' | 'unlink' | 'writeFile'> + +export type CodexDurabilityRestoreLocator = { + terminalId?: string + tabId?: string + paneId?: string + serverInstanceId?: string +} + +export class CodexDurabilityRestoreAmbiguousError extends Error { + constructor(locator: CodexDurabilityRestoreLocator, readonly matches: string[]) { + super(`Multiple Codex durability records match restore locator ${JSON.stringify(locator)}.`) + this.name = 'CodexDurabilityRestoreAmbiguousError' + } +} + +export function defaultCodexDurabilityStoreDir(): string { + return process.env.FRESHELL_CODEX_DURABILITY_DIR + || path.join(os.homedir(), '.freshell', 'codex-durability') +} + +export class CodexDurabilityStore { + private readonly dir: string + private readonly fsImpl: StoreFs + + constructor(options: { dir?: string; fsImpl?: StoreFs } = {}) { + this.dir = options.dir ?? defaultCodexDurabilityStoreDir() + this.fsImpl = options.fsImpl ?? fsp + } + + async read(terminalId: string): Promise<CodexDurabilityStoreRecord | undefined> { + const filePath = this.recordPath(terminalId) + let raw: string + try { + raw = await this.fsImpl.readFile(filePath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } + const parsed = CodexDurabilityStoreRecordSchema.safeParse(JSON.parse(raw)) + if (!parsed.success) { + throw new Error(`Codex durability store record is invalid for terminal ${terminalId}.`) + } + return parsed.data + } + + async readForRestoreLocator(locator: CodexDurabilityRestoreLocator): Promise<CodexDurabilityStoreRecord | undefined> { + if (locator.terminalId) { + return this.read(locator.terminalId) + } + if (!locator.tabId || !locator.paneId) return undefined + + let entries: string[] + try { + entries = await this.fsImpl.readdir(this.dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } + + const matches: CodexDurabilityStoreRecord[] = [] + for (const entry of entries) { + if (!entry.endsWith('.json')) continue + let terminalId: string + let record: CodexDurabilityStoreRecord | undefined + try { + terminalId = decodeURIComponent(entry.slice(0, -'.json'.length)) + record = await this.read(terminalId) + } catch { + continue + } + if (!record) continue + if (record.tabId !== locator.tabId || record.paneId !== locator.paneId) continue + if (locator.serverInstanceId && record.serverInstanceId !== locator.serverInstanceId) continue + matches.push(record) + } + + if (matches.length > 1) { + throw new CodexDurabilityRestoreAmbiguousError(locator, matches.map((match) => match.terminalId)) + } + return matches[0] + } + + async write(record: CodexDurabilityStoreRecord): Promise<CodexDurabilityStoreRecord> { + const parsed = CodexDurabilityStoreRecordSchema.parse(record) + const existing = await this.read(parsed.terminalId) + if (existing?.candidate && parsed.candidate) { + if ( + existing.candidate.candidateThreadId !== parsed.candidate.candidateThreadId + || existing.candidate.rolloutPath !== parsed.candidate.rolloutPath + ) { + throw new Error(`Codex durability candidate mismatch for terminal ${parsed.terminalId}.`) + } + } + + await this.fsImpl.mkdir(this.dir, { recursive: true }) + const filePath = this.recordPath(parsed.terminalId) + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + await this.fsImpl.writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 }) + await this.fsImpl.rename(tmpPath, filePath) + return parsed + } + + async delete(terminalId: string): Promise<void> { + try { + await this.fsImpl.unlink(this.recordPath(terminalId)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + + recordPath(terminalId: string): string { + return path.join(this.dir, `${encodeURIComponent(terminalId)}.json`) + } +} diff --git a/server/coding-cli/codex-app-server/launch-planner.ts b/server/coding-cli/codex-app-server/launch-planner.ts index 020bf3278..e8eb913d9 100644 --- a/server/coding-cli/codex-app-server/launch-planner.ts +++ b/server/coding-cli/codex-app-server/launch-planner.ts @@ -1,22 +1,40 @@ import type { CodexAppServerRuntime } from './runtime.js' -import type { CodexThreadLifecycleLossEvent } from './client.js' +import type { CodexThreadLifecycleEvent, CodexThreadLifecycleLossEvent, CodexTurnEvent } from './client.js' import { waitForAllSettledOrThrow } from '../../shutdown-join.js' +import { + CodexRemoteProxy, + type CodexRemoteProxyCandidate, + type CodexRemoteProxyRepairTrigger, +} from './remote-proxy.js' type CodexRuntimeLike = Pick< CodexAppServerRuntime, - 'ensureReady' | 'startThread' | 'listLoadedThreads' | 'shutdown' | 'updateOwnershipMetadata' | 'onThreadLifecycleLoss' + | 'ensureReady' + | 'shutdown' + | 'updateOwnershipMetadata' + | 'onThreadLifecycleLoss' + | 'onFsChanged' + | 'watchPath' + | 'unwatchPath' > export type CodexLaunchSidecar = { adopt(input: { terminalId: string; generation: number }): Promise<void> - listLoadedThreads(): Promise<string[]> + markCandidatePersisted?(): void + onCandidate?(handler: (candidate: CodexRemoteProxyCandidate) => void): () => void + onTurnStarted?(handler: (event: CodexTurnEvent) => void): () => void + onTurnCompleted?(handler: (event: CodexTurnEvent) => void): () => void + onRepairTrigger?(handler: (event: CodexRemoteProxyRepairTrigger) => void): () => void + onFsChanged?(handler: (event: { watchId: string; changedPaths: string[] }) => void): () => void + onThreadLifecycle?(handler: (event: CodexThreadLifecycleEvent) => void): () => void onLifecycleLoss?(handler: (event: CodexThreadLifecycleLossEvent) => void): () => void + watchPath?(targetPath: string, watchId: string): Promise<{ path: string }> + unwatchPath?(watchId: string): Promise<void> shutdown(): Promise<void> - waitForLoadedThread(threadId: string, options?: { timeoutMs?: number; pollMs?: number }): Promise<void> } export type CodexLaunchPlan = { - sessionId: string + sessionId?: string remote: { wsUrl: string } @@ -69,34 +87,36 @@ export class CodexLaunchPlanner { this.assertAcceptingPlans() const runtime = this.runtimeFactory() - const sidecar = this.createSidecar(runtime) + let proxy: CodexRemoteProxy | undefined + const sidecar = this.createSidecar(runtime, () => proxy) this.activeSidecars.add(sidecar) try { if (input.resumeSessionId) { const ready = await runtime.ensureReady() + proxy = new CodexRemoteProxy({ + upstreamWsUrl: ready.wsUrl, + requireCandidatePersistence: false, + }) + const proxyReady = await proxy.start() this.assertAcceptingPlans() return { sessionId: input.resumeSessionId, remote: { - wsUrl: ready.wsUrl, + wsUrl: proxyReady.wsUrl, }, sidecar, } } - const planResult = await runtime.startThread({ - cwd: input.cwd, - model: input.model, - sandbox: input.sandbox, - approvalPolicy: input.approvalPolicy, - }) + const ready = await runtime.ensureReady() + proxy = new CodexRemoteProxy({ upstreamWsUrl: ready.wsUrl }) + const proxyReady = await proxy.start() this.assertAcceptingPlans() return { - sessionId: planResult.threadId, remote: { - wsUrl: planResult.wsUrl, + wsUrl: proxyReady.wsUrl, }, sidecar, } @@ -157,7 +177,7 @@ export class CodexLaunchPlanner { } } - private createSidecar(runtime: CodexRuntimeLike): CodexLaunchSidecar { + private createSidecar(runtime: CodexRuntimeLike, getProxy: () => CodexRemoteProxy | undefined): CodexLaunchSidecar { let shutdownPromise: Promise<void> | null = null let shutdownAttemptStarted = false let shutdownSucceeded = false @@ -170,18 +190,11 @@ export class CodexLaunchPlanner { throw new Error('Codex launch sidecar is shutting down; it cannot be adopted.') } } - const assertReadable = () => { + const assertActive = () => { if (this.shutdownStarted || shutdownAttemptStarted) { - throw new Error('Codex launch sidecar is shutting down; loaded-thread readiness polling stopped.') + throw new Error('Codex launch sidecar is shutting down; remote operations stopped.') } } - const waitForNextPoll = async (pollMs: number) => { - await Promise.race([ - new Promise((resolve) => setTimeout(resolve, pollMs)), - shutdownStarted, - ]) - assertReadable() - } const sidecar: CodexLaunchSidecar = { adopt: async ({ terminalId, generation }) => { assertAdoptable() @@ -190,13 +203,32 @@ export class CodexLaunchPlanner { this.activeSidecars.delete(sidecar) this.failedSidecarShutdowns.delete(sidecar) }, - listLoadedThreads: async () => { - assertReadable() - const loaded = await runtime.listLoadedThreads() - assertReadable() - return loaded + markCandidatePersisted: () => getProxy()?.markCandidatePersisted(), + onCandidate: (handler) => getProxy()?.onCandidate(handler) ?? (() => undefined), + onTurnStarted: (handler) => getProxy()?.onTurnStarted(handler) ?? (() => undefined), + onTurnCompleted: (handler) => getProxy()?.onTurnCompleted(handler) ?? (() => undefined), + onRepairTrigger: (handler) => getProxy()?.onRepairTrigger(handler) ?? (() => undefined), + onFsChanged: (handler) => runtime.onFsChanged(handler), + onThreadLifecycle: (handler) => getProxy()?.onThreadLifecycle(handler) ?? (() => undefined), + onLifecycleLoss: (handler) => { + const unsubRuntime = runtime.onThreadLifecycleLoss(handler) + const unsubProxy = getProxy()?.onLifecycleLoss(handler) + return () => { + unsubRuntime() + unsubProxy?.() + } + }, + watchPath: async (targetPath, watchId) => { + assertActive() + const result = await runtime.watchPath(targetPath, watchId) + assertActive() + return result + }, + unwatchPath: async (watchId) => { + assertActive() + await runtime.unwatchPath(watchId) + assertActive() }, - onLifecycleLoss: (handler) => runtime.onThreadLifecycleLoss(handler), shutdown: async () => { if (shutdownSucceeded) return if (shutdownPromise) { @@ -208,7 +240,10 @@ export class CodexLaunchPlanner { notifyShutdownStarted() } const attempt = Promise.resolve() - .then(() => runtime.shutdown()) + .then(async () => { + await getProxy()?.close() + await runtime.shutdown() + }) .then(() => { shutdownSucceeded = true this.activeSidecars.delete(sidecar) @@ -227,19 +262,6 @@ export class CodexLaunchPlanner { } } }, - waitForLoadedThread: async (threadId, options = {}) => { - const timeoutMs = options.timeoutMs ?? 10_000 - const pollMs = options.pollMs ?? 100 - const deadline = Date.now() + timeoutMs - - while (Date.now() < deadline) { - const loaded = await sidecar.listLoadedThreads() - if (loaded.includes(threadId)) return - await waitForNextPoll(pollMs) - } - - throw new Error(`Codex app-server did not load thread ${threadId} within ${timeoutMs}ms.`) - }, } return sidecar } diff --git a/server/coding-cli/codex-app-server/launch-retry.ts b/server/coding-cli/codex-app-server/launch-retry.ts new file mode 100644 index 000000000..c5a0e78eb --- /dev/null +++ b/server/coding-cli/codex-app-server/launch-retry.ts @@ -0,0 +1,50 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { CodexLaunchConfigError } from '../codex-launch-config.js' +import type { CodexLaunchPlan, CodexLaunchPlanner } from './launch-planner.js' + +export const CODEX_INITIAL_LAUNCH_ATTEMPTS = 5 +const CODEX_INITIAL_LAUNCH_RETRY_DELAY_MS = 100 + +type CodexLaunchRetryLogger = { + warn: (fields: Record<string, unknown>, message: string) => void +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export async function planCodexLaunchWithRetry({ + planner, + input, + attempts = CODEX_INITIAL_LAUNCH_ATTEMPTS, + retryDelayMs = CODEX_INITIAL_LAUNCH_RETRY_DELAY_MS, + logger, +}: { + planner: CodexLaunchPlanner + input: Parameters<CodexLaunchPlanner['planCreate']>[0] + attempts?: number + retryDelayMs?: number + logger?: CodexLaunchRetryLogger +}): Promise<CodexLaunchPlan> { + let lastError: unknown + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await planner.planCreate(input) + } catch (error) { + lastError = error + if (error instanceof CodexLaunchConfigError || attempt >= attempts) break + + const delayMs = retryDelayMs * attempt + logger?.warn({ + err: error, + attempt, + attempts, + delayMs, + cwd: input.cwd, + hasResumeSessionId: Boolean(input.resumeSessionId), + }, 'Codex launch planning failed; retrying') + await delay(delayMs) + } + } + throw lastError instanceof Error ? lastError : new Error(errorMessage(lastError)) +} diff --git a/server/coding-cli/codex-app-server/protocol.ts b/server/coding-cli/codex-app-server/protocol.ts index ea3e6a906..1d4de776e 100644 --- a/server/coding-cli/codex-app-server/protocol.ts +++ b/server/coding-cli/codex-app-server/protocol.ts @@ -1,52 +1,235 @@ import { z } from 'zod' +export const CodexRequestIdSchema = z.union([z.string(), z.number().int()]) + export const CodexInitializeCapabilitiesSchema = z.object({ - experimentalApi: z.boolean(), - optOutNotificationMethods: z.array(z.string()).optional(), -}) + experimentalApi: z.boolean().default(false), + optOutNotificationMethods: z.array(z.string()).nullable().optional(), +}).strict() export const CodexInitializeParamsSchema = z.object({ clientInfo: z.object({ name: z.string().min(1), + title: z.string().nullable().optional(), version: z.string().min(1), - }), - capabilities: CodexInitializeCapabilitiesSchema.nullable(), -}) + }).strict(), + capabilities: CodexInitializeCapabilitiesSchema.nullable().optional(), +}).strict() export const CodexInitializeResultSchema = z.object({ userAgent: z.string().min(1), codexHome: z.string().min(1), platformFamily: z.string().min(1), platformOs: z.string().min(1), -}) +}).passthrough() + +export const CodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) +export const CodexSandboxModeSchema = z.enum(['read-only', 'workspace-write', 'danger-full-access']) +export const CodexNetworkAccessSchema = z.enum(['restricted', 'enabled']) +export const CodexApprovalsReviewerSchema = z.enum(['user', 'auto_review', 'guardian_subagent']) + +const CodexGranularAskForApprovalSchema = z.object({ + sandbox_approval: z.boolean(), + rules: z.boolean(), + mcp_elicitations: z.boolean(), + skill_approval: z.boolean().default(false), + request_permissions: z.boolean().default(false), +}).strict() + +export const CodexAskForApprovalSchema = z.union([ + z.enum(['untrusted', 'on-failure', 'on-request', 'never']), + z.object({ granular: CodexGranularAskForApprovalSchema }).strict(), +]) + +export const CodexSandboxPolicySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('dangerFullAccess') }).strict(), + z.object({ + type: z.literal('readOnly'), + networkAccess: z.boolean().default(false), + }).strict(), + z.object({ + type: z.literal('externalSandbox'), + networkAccess: CodexNetworkAccessSchema.default('restricted'), + }).strict(), + z.object({ + type: z.literal('workspaceWrite'), + writableRoots: z.array(z.string()).default([]), + networkAccess: z.boolean().default(false), + excludeTmpdirEnvVar: z.boolean().default(false), + excludeSlashTmp: z.boolean().default(false), + }).strict(), +]) + +export const CodexSandboxResultSchema = z.union([ + CodexSandboxModeSchema, + CodexSandboxPolicySchema, +]) + +export const CodexUserInputSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + text: z.string(), + text_elements: z.array(z.unknown()).default([]), + }).passthrough(), + z.object({ + type: z.literal('image'), + url: z.string(), + }).passthrough(), + z.object({ + type: z.literal('localImage'), + path: z.string(), + }).passthrough(), + z.object({ + type: z.literal('skill'), + name: z.string(), + path: z.string(), + }).passthrough(), + z.object({ + type: z.literal('mention'), + name: z.string(), + path: z.string(), + }).passthrough(), +]) + +export const CodexThreadStatusSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('notLoaded') }).strict(), + z.object({ type: z.literal('idle') }).strict(), + z.object({ type: z.literal('systemError') }).passthrough(), + z.object({ + type: z.literal('active'), + activeFlags: z.array(z.unknown()), + }).passthrough(), +]) + +export const CodexTurnStatusSchema = z.enum(['completed', 'interrupted', 'failed', 'inProgress']) + +export const CodexSessionSourceSchema = z.union([ + z.enum(['cli', 'vscode', 'exec', 'appServer', 'unknown']), + z.object({ custom: z.string() }).strict(), + z.object({ subAgent: z.unknown() }).strict(), +]) + +export const CodexThreadItemTypeSchema = z.enum([ + 'userMessage', + 'hookPrompt', + 'agentMessage', + 'plan', + 'reasoning', + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'dynamicToolCall', + 'collabAgentToolCall', + 'webSearch', + 'imageView', + 'imageGeneration', + 'enteredReviewMode', + 'exitedReviewMode', + 'contextCompaction', +]) + +export const CodexThreadItemSchema = z.object({ + type: CodexThreadItemTypeSchema, + id: z.string().min(1), +}).passthrough() + +export const CodexTurnSchema = z.object({ + id: z.string().min(1), + items: z.array(CodexThreadItemSchema), + status: CodexTurnStatusSchema, + error: z.unknown().nullable().optional().default(null), + startedAt: z.number().nullable().optional().default(null), + completedAt: z.number().nullable().optional().default(null), + durationMs: z.number().nullable().optional().default(null), +}).passthrough() export const CodexThreadSchema = z.object({ id: z.string().min(1), - path: z.string().min(1).nullable().optional(), - ephemeral: z.boolean().optional(), + sessionId: z.string().min(1).optional(), + preview: z.string().optional().default(''), + ephemeral: z.boolean().optional().default(false), + modelProvider: z.string().optional().default('unknown'), + createdAt: z.number().optional().default(0), + updatedAt: z.number().optional().default(0), + status: CodexThreadStatusSchema.optional().default({ type: 'idle' }), + cwd: z.string().optional().default(''), + cliVersion: z.string().optional().default(''), + source: CodexSessionSourceSchema.optional().default('unknown'), + turns: z.array(CodexTurnSchema).optional().default([]), + forkedFromId: z.string().nullable().optional().default(null), + path: z.string().nullable().optional().default(null), + agentNickname: z.string().nullable().optional().default(null), + agentRole: z.string().nullable().optional().default(null), + gitInfo: z.unknown().nullable().optional().default(null), + name: z.string().nullable().optional().default(null), }).passthrough() export const CodexThreadStartParamsSchema = z.object({ - cwd: z.string().optional(), - model: z.string().optional(), - sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - approvalPolicy: z.string().optional(), - experimentalRawEvents: z.boolean(), - persistExtendedHistory: z.boolean(), -}) + cwd: z.string().nullable().optional(), + model: z.string().nullable().optional(), + modelProvider: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), + serviceName: z.string().nullable().optional(), + sandbox: CodexSandboxModeSchema.nullable().optional(), + approvalPolicy: CodexAskForApprovalSchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + config: z.record(z.string(), z.unknown()).nullable().optional(), + baseInstructions: z.string().nullable().optional(), + developerInstructions: z.string().nullable().optional(), + personality: z.unknown().nullable().optional(), + ephemeral: z.boolean().nullable().optional(), + threadSource: z.unknown().nullable().optional(), + sessionStartSource: z.enum(['startup', 'clear']).nullable().optional(), + experimentalRawEvents: z.boolean().optional().default(false), + persistExtendedHistory: z.boolean().optional().default(false), +}).strict() export const CodexThreadResumeParamsSchema = z.object({ threadId: z.string().min(1), - cwd: z.string().optional(), - model: z.string().optional(), - sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - approvalPolicy: z.string().optional(), - persistExtendedHistory: z.boolean(), -}) + cwd: z.string().nullable().optional(), + model: z.string().nullable().optional(), + modelProvider: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), + sandbox: CodexSandboxModeSchema.nullable().optional(), + approvalPolicy: CodexAskForApprovalSchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + config: z.record(z.string(), z.unknown()).nullable().optional(), + baseInstructions: z.string().nullable().optional(), + developerInstructions: z.string().nullable().optional(), + personality: z.unknown().nullable().optional(), + persistExtendedHistory: z.boolean().optional().default(false), + excludeTurns: z.boolean().nullable().optional(), +}).strict() + +export const CodexThreadForkParamsSchema = z.object({ + threadId: z.string().min(1), + cwd: z.string().nullable().optional(), + model: z.string().nullable().optional(), + modelProvider: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), + sandbox: CodexSandboxModeSchema.nullable().optional(), + approvalPolicy: CodexAskForApprovalSchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + config: z.record(z.string(), z.unknown()).nullable().optional(), + baseInstructions: z.string().nullable().optional(), + developerInstructions: z.string().nullable().optional(), + personality: z.unknown().nullable().optional(), + ephemeral: z.boolean().nullable().optional(), + excludeTurns: z.boolean().nullable().optional(), +}).strict() export const CodexThreadOperationResultSchema = z.object({ thread: CodexThreadSchema, -}) + approvalPolicy: CodexAskForApprovalSchema, + approvalsReviewer: CodexApprovalsReviewerSchema, + cwd: z.string(), + model: z.string(), + modelProvider: z.string(), + sandbox: CodexSandboxResultSchema, + serviceTier: z.string().nullable().optional().default(null), + instructionSources: z.array(z.unknown()).optional().default([]), + reasoningEffort: CodexReasoningEffortSchema.nullable().optional().default(null), +}).passthrough() export const CodexFsWatchParamsSchema = z.object({ path: z.string().min(1), @@ -65,20 +248,82 @@ export const CodexLoadedThreadListResultSchema = z.object({ data: z.array(z.string().min(1)), }) +export const CodexThreadReadParamsSchema = z.object({ + threadId: z.string().min(1), + includeTurns: z.boolean().optional().default(false), +}).strict() + +export const CodexThreadReadResultSchema = z.object({ + thread: CodexThreadSchema, +}).passthrough() + +export const CodexThreadPageParamsSchema = z.object({ + threadId: z.string().min(1), + cursor: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), + sortDirection: z.enum(['asc', 'desc']).optional(), +}).strict() + +export const CodexThreadTurnsListResultSchema = z.object({ + revision: z.number().int().nonnegative().optional(), + nextCursor: z.string().nullable().optional().default(null), + backwardsCursor: z.string().nullable().optional().default(null), + turns: z.array(CodexTurnSchema), + bodies: z.record(z.string(), CodexTurnSchema).optional(), +}).passthrough() + +export const CodexThreadTurnReadParamsSchema = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), + revision: z.number().int().nonnegative().optional(), +}).strict() + +export const CodexThreadTurnReadResultSchema = CodexTurnSchema.extend({ + turnId: z.string().min(1).optional(), + revision: z.number().int().nonnegative().optional(), +}).passthrough() + +export const CodexTurnStartParamsSchema = z.object({ + threadId: z.string().min(1), + input: z.array(CodexUserInputSchema), + cwd: z.string().nullable().optional(), + approvalPolicy: CodexAskForApprovalSchema.nullable().optional(), + approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(), + sandboxPolicy: CodexSandboxPolicySchema.nullable().optional(), + model: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), + effort: CodexReasoningEffortSchema.nullable().optional(), + summary: z.string().nullable().optional(), + personality: z.unknown().nullable().optional(), + outputSchema: z.unknown().nullable().optional(), +}).strict() + +export const CodexTurnStartResultSchema = z.object({ + turn: CodexTurnSchema, +}).passthrough() + +export const CodexTurnInterruptParamsSchema = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), +}).strict() + +export const CodexTurnInterruptResultSchema = z.object({}).strict() + export const CodexRpcErrorSchema = z.object({ - code: z.number(), + code: z.number().int(), message: z.string().min(1), + data: z.unknown().optional(), }).passthrough() export const CodexRpcSuccessEnvelopeSchema = z.object({ - id: z.number().int(), + id: CodexRequestIdSchema, result: z.unknown(), -}).passthrough() +}).strict() export const CodexRpcErrorEnvelopeSchema = z.object({ - id: z.number().int().optional(), + id: CodexRequestIdSchema.optional(), error: CodexRpcErrorSchema, -}).passthrough() +}).strict() export const CodexRpcNotificationEnvelopeSchema = z.object({ method: z.string().min(1), @@ -125,20 +370,51 @@ export const CodexFsChangedNotificationSchema = z.object({ }), }).passthrough() +export const CodexTurnStartedNotificationSchema = z.object({ + method: z.literal('turn/started'), + params: z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1).optional(), + }).passthrough(), +}).passthrough() + +export const CodexTurnCompletedNotificationSchema = z.object({ + method: z.literal('turn/completed'), + params: z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1).optional(), + }).passthrough(), +}).passthrough() + +export type CodexRequestId = z.infer<typeof CodexRequestIdSchema> export type CodexInitializeCapabilities = z.infer<typeof CodexInitializeCapabilitiesSchema> export type CodexInitializeParams = z.infer<typeof CodexInitializeParamsSchema> export type CodexInitializeResult = z.infer<typeof CodexInitializeResultSchema> -export type CodexThreadHandle = z.infer<typeof CodexThreadSchema> -export type CodexThreadStartParams = z.infer<typeof CodexThreadStartParamsSchema> -export type CodexThreadResumeParams = z.infer<typeof CodexThreadResumeParamsSchema> +export type CodexThreadHandle = z.input<typeof CodexThreadSchema> +export type CodexThreadStartParams = z.input<typeof CodexThreadStartParamsSchema> +export type CodexThreadResumeParams = z.input<typeof CodexThreadResumeParamsSchema> +export type CodexThreadForkParams = z.input<typeof CodexThreadForkParamsSchema> export type CodexThreadOperationResult = z.infer<typeof CodexThreadOperationResultSchema> export type CodexFsWatchParams = z.infer<typeof CodexFsWatchParamsSchema> export type CodexFsWatchResult = z.infer<typeof CodexFsWatchResultSchema> export type CodexFsUnwatchParams = z.infer<typeof CodexFsUnwatchParamsSchema> export type CodexLoadedThreadListResult = z.infer<typeof CodexLoadedThreadListResultSchema> +export type CodexThreadReadParams = z.input<typeof CodexThreadReadParamsSchema> +export type CodexThreadReadResult = z.infer<typeof CodexThreadReadResultSchema> +export type CodexThreadPageParams = z.input<typeof CodexThreadPageParamsSchema> +export type CodexThreadTurnsListParams = CodexThreadPageParams +export type CodexThreadTurnsListResult = z.infer<typeof CodexThreadTurnsListResultSchema> +export type CodexThreadTurnReadParams = z.infer<typeof CodexThreadTurnReadParamsSchema> +export type CodexThreadTurnReadResult = z.infer<typeof CodexThreadTurnReadResultSchema> +export type CodexTurnStartParams = z.input<typeof CodexTurnStartParamsSchema> +export type CodexTurnStartResult = z.infer<typeof CodexTurnStartResultSchema> +export type CodexTurnInterruptParams = z.input<typeof CodexTurnInterruptParamsSchema> +export type CodexTurnInterruptResult = z.infer<typeof CodexTurnInterruptResultSchema> export type CodexRpcError = z.infer<typeof CodexRpcErrorSchema> export type CodexThreadStartedNotification = z.infer<typeof CodexThreadStartedNotificationSchema> export type CodexThreadClosedNotification = z.infer<typeof CodexThreadClosedNotificationSchema> export type CodexThreadStatusChangedNotification = z.infer<typeof CodexThreadStatusChangedNotificationSchema> export type CodexThreadLifecycleNotification = z.infer<typeof CodexThreadLifecycleNotificationSchema> export type CodexFsChangedNotification = z.infer<typeof CodexFsChangedNotificationSchema> +export type CodexTurnStartedNotification = z.infer<typeof CodexTurnStartedNotificationSchema> +export type CodexTurnCompletedNotification = z.infer<typeof CodexTurnCompletedNotificationSchema> diff --git a/server/coding-cli/codex-app-server/remote-proxy.ts b/server/coding-cli/codex-app-server/remote-proxy.ts new file mode 100644 index 000000000..ec6ccc9cd --- /dev/null +++ b/server/coding-cli/codex-app-server/remote-proxy.ts @@ -0,0 +1,534 @@ +import WebSocket, { WebSocketServer } from 'ws' +import { allocateLocalhostPort, type LoopbackServerEndpoint } from '../../local-port.js' +import { + CodexFsChangedNotificationSchema, + CodexThreadLifecycleNotificationSchema, + CodexTurnCompletedNotificationSchema, + CodexTurnStartedNotificationSchema, + type CodexThreadHandle, +} from './protocol.js' +import type { CodexThreadLifecycleEvent, CodexThreadLifecycleLossEvent, CodexTurnEvent } from './client.js' +import { logger } from '../../logger.js' + +const log = logger.child({ component: 'codex-remote-proxy' }) + +export type CodexRemoteProxyCandidate = { + thread: CodexThreadHandle + source: 'thread_start_response' | 'thread_started_notification' +} + +export type CodexRemoteProxyRepairTrigger = + | { kind: 'proxy_close' | 'proxy_error' | 'candidate_capture_timeout'; error?: Error } + | { kind: 'fs_changed'; watchId: string; changedPaths: string[] } + +type JsonRpcId = string | number + +type PendingTurnStart = { + raw: WebSocket.RawData | string + client: WebSocket + upstream: WebSocket + id?: JsonRpcId + timer: NodeJS.Timeout +} + +type ProxyConnection = { + client: WebSocket + upstream: WebSocket + pendingMethods: Map<JsonRpcId, string> +} + +type CodexRemoteProxyOptions = { + upstreamWsUrl: string + portAllocator?: () => Promise<LoopbackServerEndpoint> + requestHoldTimeoutMs?: number + candidateCaptureTimeoutMs?: number + requireCandidatePersistence?: boolean +} + +const DEFAULT_REQUEST_HOLD_TIMEOUT_MS = 5_000 +const DEFAULT_CANDIDATE_CAPTURE_TIMEOUT_MS = 45_000 + +export class CodexRemoteProxy { + private readonly upstreamWsUrl: string + private readonly portAllocator: () => Promise<LoopbackServerEndpoint> + private readonly requestHoldTimeoutMs: number + private readonly candidateCaptureTimeoutMs: number + private readonly requireCandidatePersistence: boolean + private server: WebSocketServer | null = null + private endpoint: LoopbackServerEndpoint | null = null + private candidatePersisted = false + private candidateCaptureFailed = false + private candidateCaptureTimer: NodeJS.Timeout | null = null + private readonly pendingTurnStarts = new Set<PendingTurnStart>() + private readonly connections = new Set<ProxyConnection>() + private readonly candidateHandlers = new Set<(candidate: CodexRemoteProxyCandidate) => void>() + private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>() + private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>() + private readonly repairTriggerHandlers = new Set<(event: CodexRemoteProxyRepairTrigger) => void>() + private readonly lifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>() + private readonly lifecycleLossHandlers = new Set<(event: CodexThreadLifecycleLossEvent) => void>() + + constructor(options: CodexRemoteProxyOptions) { + this.upstreamWsUrl = options.upstreamWsUrl + this.portAllocator = options.portAllocator ?? allocateLocalhostPort + this.requestHoldTimeoutMs = options.requestHoldTimeoutMs ?? DEFAULT_REQUEST_HOLD_TIMEOUT_MS + this.candidateCaptureTimeoutMs = options.candidateCaptureTimeoutMs ?? DEFAULT_CANDIDATE_CAPTURE_TIMEOUT_MS + this.requireCandidatePersistence = options.requireCandidatePersistence ?? true + this.candidatePersisted = !this.requireCandidatePersistence + } + + get wsUrl(): string { + if (!this.endpoint) { + throw new Error('Codex remote proxy has not been started.') + } + return `ws://${this.endpoint.hostname}:${this.endpoint.port}` + } + + async start(): Promise<{ wsUrl: string }> { + if (this.server) return { wsUrl: this.wsUrl } + const endpoint = await this.portAllocator() + await new Promise<void>((resolve, reject) => { + const server = new WebSocketServer({ host: endpoint.hostname, port: endpoint.port }, () => resolve()) + server.once('error', reject) + server.on('connection', (client) => this.handleClientConnection(client)) + this.server = server + this.endpoint = endpoint + }) + if (this.requireCandidatePersistence) { + this.ensureCandidateCaptureTimer() + } + log.info({ + wsUrl: this.wsUrl, + upstreamWsUrl: this.upstreamWsUrl, + requireCandidatePersistence: this.requireCandidatePersistence, + }, 'Codex remote proxy listening') + return { wsUrl: this.wsUrl } + } + + async close(): Promise<void> { + this.clearCandidateCaptureTimer() + for (const pending of [...this.pendingTurnStarts]) { + this.failHeldTurnStart(pending, 'Codex remote proxy is closing before restore identity persistence completed.') + } + for (const connection of [...this.connections]) { + connection.client.close() + connection.upstream.close() + } + const server = this.server + this.server = null + this.endpoint = null + if (!server) return + await new Promise<void>((resolve) => server.close(() => resolve())) + } + + markCandidatePersisted(): void { + if (this.candidatePersisted) return + if (this.candidateCaptureFailed) return + this.candidatePersisted = true + this.clearCandidateCaptureTimer() + for (const pending of [...this.pendingTurnStarts]) { + this.releaseHeldTurnStart(pending) + } + } + + failCandidateCapture(message = 'Freshell could not persist Codex restore identity before accepting user input.'): void { + if (!this.requireCandidatePersistence) return + if (this.candidateCaptureFailed || this.candidatePersisted) return + this.candidateCaptureFailed = true + this.clearCandidateCaptureTimer() + this.emitRepairTrigger({ kind: 'candidate_capture_timeout' }) + for (const pending of [...this.pendingTurnStarts]) { + this.failHeldTurnStart(pending, message) + } + for (const connection of [...this.connections]) { + this.sendJsonRpcError(connection.client, undefined, message) + connection.client.close() + connection.upstream.close() + } + } + + onCandidate(handler: (candidate: CodexRemoteProxyCandidate) => void): () => void { + this.candidateHandlers.add(handler) + return () => this.candidateHandlers.delete(handler) + } + + onTurnStarted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnStartedHandlers.add(handler) + return () => this.turnStartedHandlers.delete(handler) + } + + onTurnCompleted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnCompletedHandlers.add(handler) + return () => this.turnCompletedHandlers.delete(handler) + } + + onRepairTrigger(handler: (event: CodexRemoteProxyRepairTrigger) => void): () => void { + this.repairTriggerHandlers.add(handler) + return () => this.repairTriggerHandlers.delete(handler) + } + + onThreadLifecycle(handler: (event: CodexThreadLifecycleEvent) => void): () => void { + this.lifecycleHandlers.add(handler) + return () => this.lifecycleHandlers.delete(handler) + } + + onLifecycleLoss(handler: (event: CodexThreadLifecycleLossEvent) => void): () => void { + this.lifecycleLossHandlers.add(handler) + return () => this.lifecycleLossHandlers.delete(handler) + } + + private handleClientConnection(client: WebSocket): void { + if (this.candidateCaptureFailed) { + this.sendJsonRpcError(client, undefined, 'Freshell timed out before Codex restore identity was captured.') + client.close() + return + } + const upstream = new WebSocket(this.upstreamWsUrl) + const connection: ProxyConnection = { + client, + upstream, + pendingMethods: new Map(), + } + this.connections.add(connection) + if (this.requireCandidatePersistence) { + this.ensureCandidateCaptureTimer() + } + log.info({ + proxyWsUrl: this.wsUrl, + upstreamWsUrl: this.upstreamWsUrl, + requireCandidatePersistence: this.requireCandidatePersistence, + activeConnections: this.connections.size, + }, 'Codex remote proxy client connected') + + client.on('message', (raw, isBinary) => this.handleClientMessage(connection, raw, isBinary)) + upstream.on('message', (raw, isBinary) => this.handleUpstreamMessage(connection, raw, isBinary)) + upstream.on('open', () => { + log.info({ + proxyWsUrl: this.wsUrl, + upstreamWsUrl: this.upstreamWsUrl, + }, 'Codex remote proxy upstream connected') + }) + + const closeBoth = () => { + this.connections.delete(connection) + client.close() + upstream.close() + } + client.on('close', (code, reason) => { + log.info({ + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + code, + reason: reason.toString(), + activeConnections: Math.max(0, this.connections.size - 1), + }, 'Codex remote proxy client closed') + closeBoth() + }) + upstream.on('close', (code, reason) => { + log.warn({ + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + code, + reason: reason.toString(), + activeConnections: Math.max(0, this.connections.size - 1), + }, 'Codex remote proxy upstream closed') + this.emitRepairTrigger({ kind: 'proxy_close' }) + closeBoth() + }) + client.on('error', (error) => { + log.warn({ + err: error, + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + }, 'Codex remote proxy client error') + this.emitRepairTrigger({ kind: 'proxy_error', error }) + closeBoth() + }) + upstream.on('error', (error) => { + log.warn({ + err: error, + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + }, 'Codex remote proxy upstream error') + this.emitRepairTrigger({ kind: 'proxy_error', error }) + closeBoth() + }) + } + + private handleClientMessage(connection: ProxyConnection, raw: WebSocket.RawData, isBinary: boolean): void { + const forward = framePayload(raw, isBinary) + const parsed = parseJson(raw) + const method = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>).method : undefined + const id = jsonRpcId(parsed) + if (id !== undefined && typeof method === 'string') { + connection.pendingMethods.set(id, method) + } + if (typeof method === 'string') { + log.debug({ + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + method, + id, + }, 'Codex remote proxy forwarding client request') + } + + if (this.requireCandidatePersistence && method === 'turn/start' && !this.candidatePersisted) { + this.holdTurnStart(connection, forward, id) + return + } + + sendIfOpen(connection.upstream, forward) + } + + private handleUpstreamMessage(connection: ProxyConnection, raw: WebSocket.RawData, isBinary: boolean): void { + const forward = framePayload(raw, isBinary) + const parsed = parseJson(raw) + const id = jsonRpcId(parsed) + if (id !== undefined) { + const method = connection.pendingMethods.get(id) + connection.pendingMethods.delete(id) + log.debug({ + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + method, + id, + }, 'Codex remote proxy forwarding upstream response') + if (method === 'thread/start') { + this.maybeEmitThreadStartResponseCandidate(parsed) + } + } else { + const method = parsed && typeof parsed === 'object' + ? (parsed as Record<string, unknown>).method + : undefined + if (typeof method === 'string') { + log.debug({ + proxyWsUrl: this.endpoint ? this.wsUrl : undefined, + upstreamWsUrl: this.upstreamWsUrl, + method, + }, 'Codex remote proxy forwarding upstream notification') + } + this.handleUpstreamNotification(parsed) + } + sendIfOpen(connection.client, forward) + } + + private maybeEmitThreadStartResponseCandidate(parsed: unknown): void { + if (!parsed || typeof parsed !== 'object') return + const result = (parsed as Record<string, unknown>).result + const thread = result && typeof result === 'object' + ? normalizeCandidateThread((result as Record<string, unknown>).thread) + : undefined + if (!thread) return + this.emitCandidate({ + thread, + source: 'thread_start_response', + }) + } + + private handleUpstreamNotification(parsed: unknown): void { + const method = parsed && typeof parsed === 'object' + ? (parsed as Record<string, unknown>).method + : undefined + if (method === 'thread/started') { + const params = (parsed as Record<string, unknown>).params + const thread = params && typeof params === 'object' + ? normalizeCandidateThread((params as Record<string, unknown>).thread) + : undefined + if (!thread) return + this.emitCandidate({ + thread, + source: 'thread_started_notification', + }) + this.emitThreadLifecycle({ + kind: 'thread_started', + thread, + }) + return + } + + const turnStarted = CodexTurnStartedNotificationSchema.safeParse(parsed) + if (turnStarted.success) { + this.emitTurnEvent(this.turnStartedHandlers, turnStarted.data.params) + return + } + + const turnCompleted = CodexTurnCompletedNotificationSchema.safeParse(parsed) + if (turnCompleted.success) { + this.emitTurnEvent(this.turnCompletedHandlers, turnCompleted.data.params) + return + } + + const fsChanged = CodexFsChangedNotificationSchema.safeParse(parsed) + if (fsChanged.success) { + this.emitRepairTrigger({ kind: 'fs_changed', ...fsChanged.data.params }) + return + } + + const lifecycle = CodexThreadLifecycleNotificationSchema.safeParse(parsed) + if (lifecycle.success) { + if (lifecycle.data.method === 'thread/closed') { + this.emitThreadLifecycle({ kind: 'thread_closed', threadId: lifecycle.data.params.threadId }) + this.emitLifecycleLoss({ method: 'thread/closed', threadId: lifecycle.data.params.threadId }) + } else if (lifecycle.data.method === 'thread/status/changed') { + this.emitThreadLifecycle({ + kind: 'thread_status_changed', + threadId: lifecycle.data.params.threadId, + status: lifecycle.data.params.status, + }) + const status = lifecycle.data.params.status.type + if (status === 'notLoaded' || status === 'systemError') { + this.emitLifecycleLoss({ + method: 'thread/status/changed', + threadId: lifecycle.data.params.threadId, + status, + }) + } + } + } + } + + private holdTurnStart(connection: ProxyConnection, raw: WebSocket.RawData | string, id?: JsonRpcId): void { + const pending: PendingTurnStart = { + raw, + client: connection.client, + upstream: connection.upstream, + id, + timer: setTimeout(() => { + this.failHeldTurnStart( + pending, + 'Freshell could not persist Codex restore identity before accepting user input.', + ) + }, this.requestHoldTimeoutMs), + } + pending.timer.unref?.() + this.pendingTurnStarts.add(pending) + } + + private releaseHeldTurnStart(pending: PendingTurnStart): void { + if (!this.pendingTurnStarts.delete(pending)) return + clearTimeout(pending.timer) + sendIfOpen(pending.upstream, pending.raw) + } + + private failHeldTurnStart(pending: PendingTurnStart, message: string): void { + if (!this.pendingTurnStarts.delete(pending)) return + clearTimeout(pending.timer) + this.emitRepairTrigger({ kind: 'candidate_capture_timeout' }) + this.sendJsonRpcError(pending.client, pending.id, message) + pending.client.close() + pending.upstream.close() + } + + private sendJsonRpcError(client: WebSocket, id: JsonRpcId | undefined, message: string): void { + sendIfOpen(client, JSON.stringify({ + jsonrpc: '2.0', + ...(id !== undefined ? { id } : {}), + error: { + code: -32000, + message, + }, + })) + } + + private ensureCandidateCaptureTimer(): void { + if (!this.requireCandidatePersistence) return + if (this.candidatePersisted || this.candidateCaptureTimer) return + this.candidateCaptureTimer = setTimeout(() => { + this.failCandidateCapture('Freshell timed out before Codex restore identity was captured.') + }, this.candidateCaptureTimeoutMs) + this.candidateCaptureTimer.unref?.() + } + + private clearCandidateCaptureTimer(): void { + if (!this.candidateCaptureTimer) return + clearTimeout(this.candidateCaptureTimer) + this.candidateCaptureTimer = null + } + + private emitCandidate(candidate: CodexRemoteProxyCandidate): void { + log.info({ + threadId: candidate.thread.id, + rolloutPath: candidate.thread.path, + source: candidate.source, + }, 'Codex remote proxy observed candidate restore identity') + for (const handler of this.candidateHandlers) { + handler(candidate) + } + } + + private emitTurnEvent(handlers: Set<(event: CodexTurnEvent) => void>, params: { threadId: string; turnId?: string } & Record<string, unknown>): void { + const event: CodexTurnEvent = { + threadId: params.threadId, + ...(typeof params.turnId === 'string' ? { turnId: params.turnId } : {}), + params, + } + for (const handler of handlers) { + handler(event) + } + } + + private emitRepairTrigger(event: CodexRemoteProxyRepairTrigger): void { + for (const handler of this.repairTriggerHandlers) { + handler(event) + } + } + + private emitThreadLifecycle(event: CodexThreadLifecycleEvent): void { + for (const handler of this.lifecycleHandlers) { + handler(event) + } + } + + private emitLifecycleLoss(event: CodexThreadLifecycleLossEvent): void { + for (const handler of this.lifecycleLossHandlers) { + handler(event) + } + } +} + +function parseJson(raw: WebSocket.RawData): unknown { + try { + return JSON.parse(raw.toString()) + } catch { + return undefined + } +} + +function jsonRpcId(parsed: unknown): JsonRpcId | undefined { + if (!parsed || typeof parsed !== 'object') return undefined + const id = (parsed as Record<string, unknown>).id + return typeof id === 'string' || typeof id === 'number' ? id : undefined +} + +function framePayload(raw: WebSocket.RawData, isBinary: boolean): WebSocket.RawData | string { + return isBinary ? raw : raw.toString() +} + +function sendIfOpen(socket: WebSocket, data: WebSocket.RawData | string): void { + if (socket.readyState === WebSocket.OPEN) { + socket.send(data) + } else if (socket.readyState === WebSocket.CONNECTING) { + socket.once('open', () => { + if (socket.readyState === WebSocket.OPEN) socket.send(data) + }) + } +} + +function normalizeCandidateThread(thread: unknown): CodexThreadHandle | undefined { + if (!thread || typeof thread !== 'object') return undefined + const candidate = thread as Record<string, unknown> + if (typeof candidate.id !== 'string' || candidate.id.length === 0) return undefined + return { + id: candidate.id, + path: typeof candidate.path === 'string' ? candidate.path : null, + ephemeral: typeof candidate.ephemeral === 'boolean' ? candidate.ephemeral : false, + } +} + +function normalizeThread(thread: CodexThreadHandle): CodexThreadHandle { + return { + ...thread, + path: thread.path ?? null, + ephemeral: thread.ephemeral ?? false, + } +} diff --git a/server/coding-cli/codex-app-server/restore-decision.ts b/server/coding-cli/codex-app-server/restore-decision.ts new file mode 100644 index 000000000..21be058d4 --- /dev/null +++ b/server/coding-cli/codex-app-server/restore-decision.ts @@ -0,0 +1,179 @@ +import type { SessionRef, RestoreError } from '../../../shared/session-contract.js' +import { buildRestoreError } from '../../../shared/session-contract.js' +import type { CodexCandidateIdentity, CodexDurabilityRef } from '../../../shared/codex-durability.js' +import { proofCodexRollout, type CodexRolloutProofResult } from './durability-proof.js' + +type MaybePromise<T> = T | Promise<T> + +export type CodexLiveRestoreTerminal = { + terminalId: string + createdAt: number + resumeSessionId?: string + codexDurability?: CodexDurabilityRef +} + +export type RejectCodexCreateRestoreDecision = { + kind: 'reject_invalid_raw_codex_resume_request' | 'reject_missing_codex_session_ref' + code: 'INVALID_MESSAGE' | 'RESTORE_UNAVAILABLE' + message: string +} + +export type CodexCreateRestorePlan = + | RejectCodexCreateRestoreDecision + | { kind: 'fresh_codex_launch' } + | { kind: 'proof_existing_candidate_first'; candidate: CodexCandidateIdentity } + | { kind: 'durable_session_ref_resume'; sessionRef: SessionRef & { provider: 'codex' }; sessionId: string } + +export type CodexCreateRestoreDecision<TLiveTerminal extends CodexLiveRestoreTerminal = CodexLiveRestoreTerminal> = + | Exclude<CodexCreateRestorePlan, { kind: 'proof_existing_candidate_first' }> + | { + kind: 'proof_succeeded_resume_durable' + candidate: CodexCandidateIdentity + proof: Extract<CodexRolloutProofResult, { ok: true }> + sessionId: string + liveTerminal?: TLiveTerminal + } + | { + kind: 'proof_failed_attach_live_candidate' + candidate: CodexCandidateIdentity + proof: Extract<CodexRolloutProofResult, { ok: false }> + liveTerminal: TLiveTerminal + } + | { + kind: 'proof_failed_fresh_create' + candidate: CodexCandidateIdentity + proof: Extract<CodexRolloutProofResult, { ok: false }> + clearCodexDurability: true + restoreError: RestoreError + } + +export const INVALID_RAW_CODEX_RESUME_MESSAGE = + 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.' + +export const MISSING_CODEX_SESSION_REF_MESSAGE = 'Restore requires a canonical session reference.' + +export function planCodexCreateRestoreDecision(input: { + restoreRequested?: boolean + legacyResumeSessionId?: string + sessionRef?: SessionRef + codexDurability?: CodexDurabilityRef +}): CodexCreateRestorePlan { + const codexSessionRef = isCodexSessionRef(input.sessionRef) ? input.sessionRef : undefined + + if (hasRawLegacyResume(input.legacyResumeSessionId) && !codexSessionRef) { + return { + kind: 'reject_invalid_raw_codex_resume_request', + code: 'INVALID_MESSAGE', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + } + } + + if (codexSessionRef) { + return { + kind: 'durable_session_ref_resume', + sessionRef: codexSessionRef, + sessionId: codexSessionRef.sessionId, + } + } + + const durableSessionId = input.restoreRequested ? getDurableCodexSessionId(input.codexDurability) : undefined + if (durableSessionId) { + return { + kind: 'durable_session_ref_resume', + sessionRef: { provider: 'codex', sessionId: durableSessionId }, + sessionId: durableSessionId, + } + } + + const candidate = input.codexDurability?.candidate + if (input.restoreRequested && candidate && !input.legacyResumeSessionId) { + return { + kind: 'proof_existing_candidate_first', + candidate, + } + } + + if (input.restoreRequested) { + return { + kind: 'reject_missing_codex_session_ref', + code: 'RESTORE_UNAVAILABLE', + message: MISSING_CODEX_SESSION_REF_MESSAGE, + } + } + + return { kind: 'fresh_codex_launch' } +} + +export async function resolveCodexCreateRestoreDecision<TLiveTerminal extends CodexLiveRestoreTerminal>( + input: { + restoreRequested?: boolean + legacyResumeSessionId?: string + sessionRef?: SessionRef + codexDurability?: CodexDurabilityRef + proofRollout?: (input: { rolloutPath: string; candidateThreadId: string }) => Promise<CodexRolloutProofResult> + findLiveTerminalByCandidate?: (candidate: CodexCandidateIdentity) => MaybePromise<TLiveTerminal | undefined> + }, +): Promise<CodexCreateRestoreDecision<TLiveTerminal>> { + const plan = planCodexCreateRestoreDecision(input) + if (plan.kind !== 'proof_existing_candidate_first') { + return plan + } + + const candidate = plan.candidate + const proof = await (input.proofRollout ?? proofCodexRollout)({ + rolloutPath: candidate.rolloutPath, + candidateThreadId: candidate.candidateThreadId, + }) + const returnedLiveTerminal = await input.findLiveTerminalByCandidate?.(candidate) + const liveTerminal = returnedLiveTerminal && isExactLiveCodexCandidate(returnedLiveTerminal, candidate) + ? returnedLiveTerminal + : undefined + + if (proof.ok) { + return { + kind: 'proof_succeeded_resume_durable', + candidate, + proof, + sessionId: proof.rolloutProofId, + ...(liveTerminal ? { liveTerminal } : {}), + } + } + + if (liveTerminal) { + return { + kind: 'proof_failed_attach_live_candidate', + candidate, + proof, + liveTerminal, + } + } + + return { + kind: 'proof_failed_fresh_create', + candidate, + proof, + clearCodexDurability: true, + restoreError: buildRestoreError('durable_artifact_missing'), + } +} + +function isCodexSessionRef(value: SessionRef | undefined): value is SessionRef & { provider: 'codex' } { + return value?.provider === 'codex' +} + +function hasRawLegacyResume(value: string | undefined): boolean { + return typeof value === 'string' && value.length > 0 +} + +function getDurableCodexSessionId(value: CodexDurabilityRef | undefined): string | undefined { + return value?.state === 'durable' ? value.durableThreadId : undefined +} + +export function isExactLiveCodexCandidate( + terminal: CodexLiveRestoreTerminal, + candidate: Pick<CodexCandidateIdentity, 'candidateThreadId' | 'rolloutPath'>, +): boolean { + const liveCandidate = terminal.codexDurability?.candidate + return liveCandidate?.candidateThreadId === candidate.candidateThreadId + && liveCandidate.rolloutPath === candidate.rolloutPath +} diff --git a/server/coding-cli/codex-app-server/runtime.ts b/server/coding-cli/codex-app-server/runtime.ts index 96471533c..4f55d3f75 100644 --- a/server/coding-cli/codex-app-server/runtime.ts +++ b/server/coding-cli/codex-app-server/runtime.ts @@ -3,19 +3,30 @@ import { spawn } from 'node:child_process' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { CODEX_MANAGED_REMOTE_CONFIG_ARGS } from '../codex-managed-config.js' import { allocateLocalhostPort, type LoopbackServerEndpoint } from '../../local-port.js' import { logger } from '../../logger.js' import { CodexAppServerClient, + type CodexTurnEvent, type CodexThreadLifecycleEvent, type CodexThreadLifecycleLossEvent, } from './client.js' import type { CodexFsWatchResult, CodexInitializeResult, + CodexThreadForkParams, CodexThreadHandle, + CodexThreadReadParams, + CodexThreadReadResult, CodexThreadResumeParams, CodexThreadStartParams, + CodexThreadTurnReadParams, + CodexThreadTurnReadResult, + CodexThreadTurnsListParams, + CodexThreadTurnsListResult, + CodexTurnInterruptParams, + CodexTurnStartParams, } from './protocol.js' type RuntimeStatus = 'running' | 'stopped' @@ -49,6 +60,7 @@ export type CodexSidecarOwnershipMetadata = { export type ReadyState = { wsUrl: string processPid: number + codexHome: string ownershipId: string processGroupId: number metadataPath: string @@ -95,6 +107,7 @@ const DEFAULT_STARTUP_ATTEMPT_TIMEOUT_MS = 3_000 const STARTUP_POLL_MS = 50 const DEFAULT_TERMINATE_GRACE_MS = 1_000 const OWNERSHIP_SCHEMA_VERSION = 1 +export const DEFAULT_CODEX_SIDECAR_METADATA_DIR = path.join(os.homedir(), '.freshell', 'codex-sidecars') function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -102,7 +115,7 @@ function sleep(ms: number): Promise<void> { function defaultMetadataDir(): string { return process.env.FRESHELL_CODEX_SIDECAR_DIR - || path.join(os.homedir(), '.freshell', 'codex-sidecars') + || DEFAULT_CODEX_SIDECAR_METADATA_DIR } function assertUnixSidecarSupport(): void { @@ -478,16 +491,25 @@ export async function runCodexStartupReaper( export const reapOrphanedCodexAppServerSidecarsOnStartup = runCodexStartupReaper export function assertCodexStartupReaperSucceeded(result: ReapOrphanedSidecarsResult): void { - const unreapedOwnershipIds = [ - ...result.failedOwnershipIds, - ...result.skippedActiveOwnershipIds, - ] - if (unreapedOwnershipIds.length === 0) return + const failedOwnershipIds = [...new Set(result.failedOwnershipIds)] + const activeOwnershipIds = [...new Set(result.skippedActiveOwnershipIds)] + if (failedOwnershipIds.length === 0 && activeOwnershipIds.length === 0) return + + const reasons: string[] = [] + if (failedOwnershipIds.length > 0) { + reasons.push( + `failed to reap ${failedOwnershipIds.length} ownership record(s): ${failedOwnershipIds.join(', ')}`, + ) + } + if (activeOwnershipIds.length > 0) { + reasons.push( + `${activeOwnershipIds.length} ownership record(s) still owned by a live Freshell server/process: ${activeOwnershipIds.join(', ')}`, + ) + } - const blockedOwnershipIds = [...new Set(unreapedOwnershipIds)] throw new Error( - `Codex app-server startup reaper failed to reap ${blockedOwnershipIds.length} ownership record(s): ${blockedOwnershipIds.join(', ')}. ` - + 'Refusing to continue until the unreaped Codex sidecar ownership is verified gone or handled explicitly.', + `Codex app-server startup reaper blocked startup: ${reasons.join('; ')}. ` + + 'Refusing to continue until failed ownership records are handled and active owners have shut down or been verified gone.', ) } @@ -506,6 +528,8 @@ export class CodexAppServerRuntime { private readonly threadStartedHandlers = new Set<(thread: CodexThreadHandle) => void>() private readonly threadLifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>() private readonly fsChangedHandlers = new Set<(event: { watchId: string; changedPaths: string[] }) => void>() + private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>() + private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>() private readonly command: string private readonly commandArgs: string[] @@ -560,8 +584,9 @@ export class CodexAppServerRuntime { params: Omit<CodexThreadStartParams, 'experimentalRawEvents' | 'persistExtendedHistory'>, ): Promise<{ threadId: string; wsUrl: string }> { const ready = await this.ensureReady() + const result = await this.client!.startThread(params) return { - ...(await this.client!.startThread(params)), + threadId: result.thread.id, wsUrl: ready.wsUrl, } } @@ -570,12 +595,47 @@ export class CodexAppServerRuntime { params: Omit<CodexThreadResumeParams, 'persistExtendedHistory'>, ): Promise<{ threadId: string; wsUrl: string }> { const ready = await this.ensureReady() + const result = await this.client!.resumeThread(params) return { - ...(await this.client!.resumeThread(params)), + threadId: result.thread.id, wsUrl: ready.wsUrl, } } + async forkThread(params: CodexThreadForkParams): Promise<{ threadId: string; wsUrl: string }> { + const ready = await this.ensureReady() + const result = await this.client!.forkThread(params) + return { + threadId: result.threadId, + wsUrl: ready.wsUrl, + } + } + + async readThread(params: CodexThreadReadParams): Promise<CodexThreadReadResult> { + await this.ensureReady() + return this.client!.readThread(params) + } + + async listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResult> { + await this.ensureReady() + return this.client!.listThreadTurns(params) + } + + async readThreadTurn(params: CodexThreadTurnReadParams): Promise<CodexThreadTurnReadResult> { + await this.ensureReady() + return this.client!.readThreadTurn(params) + } + + async startTurn(params: CodexTurnStartParams): Promise<{ turnId: string }> { + await this.ensureReady() + return this.client!.startTurn(params) + } + + async interruptTurn(params: CodexTurnInterruptParams): Promise<void> { + await this.ensureReady() + await this.client!.interruptTurn(params) + } + async listLoadedThreads(): Promise<string[]> { await this.ensureReady() return this.client!.listLoadedThreads() @@ -635,6 +695,20 @@ export class CodexAppServerRuntime { } } + onTurnStarted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnStartedHandlers.add(handler) + return () => { + this.turnStartedHandlers.delete(handler) + } + } + + onTurnCompleted(handler: (event: CodexTurnEvent) => void): () => void { + this.turnCompletedHandlers.add(handler) + return () => { + this.turnCompletedHandlers.delete(handler) + } + } + async watchPath(targetPath: string, watchId: string): Promise<CodexFsWatchResult> { await this.ensureReady() return this.client!.watchPath(targetPath, watchId) @@ -690,6 +764,7 @@ export class CodexAppServerRuntime { const ownershipId = this.ownershipIdFactory() const child = spawn(this.command, [ ...this.commandArgs, + ...CODEX_MANAGED_REMOTE_CONFIG_ARGS, 'app-server', '--listen', wsUrl, @@ -760,6 +835,16 @@ export class CodexAppServerRuntime { handler(event) } }) + client.onTurnStarted((event) => { + for (const handler of this.turnStartedHandlers) { + handler(event) + } + }) + client.onTurnCompleted((event) => { + for (const handler of this.turnCompletedHandlers) { + handler(event) + } + }) client.onDisconnect((event) => { if (this.shutdownRequested) return for (const handler of this.exitHandlers) { @@ -774,6 +859,7 @@ export class CodexAppServerRuntime { return { wsUrl, processPid: child.pid, + codexHome: initialized.codexHome, ownershipId, processGroupId: child.pid, metadataPath: ownership.metadataPath, @@ -842,17 +928,25 @@ export class CodexAppServerRuntime { } private async readWrapperIdentityInto(ownership: ActiveOwnership): Promise<void> { - const wrapperIdentity = await this.processIdentityReader(ownership.metadata.wrapperPid) - if (!isCompleteWrapperIdentity(wrapperIdentity)) { - throw new Error( - `Codex app-server wrapper identity could not be completely read for PID ${ownership.metadata.wrapperPid}.`, - ) - } - ownership.metadata = { - ...ownership.metadata, - wrapperIdentity, - updatedAt: new Date().toISOString(), + const timeoutMs = Math.min(this.startupAttemptTimeoutMs, 1_000) + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const wrapperIdentity = await this.processIdentityReader(ownership.metadata.wrapperPid) + if (isCompleteWrapperIdentity(wrapperIdentity)) { + ownership.metadata = { + ...ownership.metadata, + wrapperIdentity, + updatedAt: new Date().toISOString(), + } + return + } + await sleep(25) } + + throw new Error( + `Codex app-server wrapper identity could not be completely read for PID ${ownership.metadata.wrapperPid}.`, + ) } private async writeOwnershipRecord(ownership: ActiveOwnership): Promise<void> { diff --git a/server/coding-cli/codex-managed-config.ts b/server/coding-cli/codex-managed-config.ts new file mode 100644 index 000000000..d3a6371a9 --- /dev/null +++ b/server/coding-cli/codex-managed-config.ts @@ -0,0 +1 @@ +export const CODEX_MANAGED_REMOTE_CONFIG_ARGS = ['-c', 'features.apps=false'] as const diff --git a/server/coding-cli/opencode-activity-tracker.ts b/server/coding-cli/opencode-activity-tracker.ts index a51364299..3279ebd6a 100644 --- a/server/coding-cli/opencode-activity-tracker.ts +++ b/server/coding-cli/opencode-activity-tracker.ts @@ -2,6 +2,16 @@ import { EventEmitter } from 'events' import { z } from 'zod' import type { OpencodeServerEndpoint } from '../local-port.js' import { logger } from '../logger.js' +import type { OpencodeRootResolution } from './providers/opencode.js' +import { + confirmOpencodeAssociation, + createOpencodeOwnershipState, + reduceOpencodeOwnership, + rejectOpencodeAssociation, + type OpencodeObservation, + type OpencodeOwnershipAction, + type OpencodeOwnershipState, +} from './opencode-ownership-reducer.js' export const OPENCODE_HEALTH_POLL_MS = 200 // Applies per health-wait cycle; connection failures restart the cycle after backoff. @@ -23,6 +33,17 @@ export type OpencodeActivityChange = { remove: string[] } +export type OpencodeAssociationRequestedEvent = { + terminalId: string + sessionId: string +} + +export type OpencodeTurnCompleteEvent = { + terminalId: string + sessionId: string + at: number +} + const SessionIdleStatusSchema = z.object({ type: z.literal('idle'), }).passthrough() @@ -62,10 +83,22 @@ const SessionIdleEventSchema = z.object({ }).passthrough(), }).passthrough() +const SessionCreatedEventSchema = z.object({ + type: z.literal('session.created'), + properties: z.object({ + sessionID: z.string().min(1), + info: z.object({ + id: z.string().min(1), + parentID: z.string().nullable().optional(), + }).passthrough(), + }).passthrough(), +}).passthrough() + const OpencodeEventSchema = z.discriminatedUnion('type', [ ServerConnectedEventSchema, SessionStatusEventSchema, SessionIdleEventSchema, + SessionCreatedEventSchema, ]) const OpencodeEventTypeSchema = z.object({ @@ -76,6 +109,7 @@ const KNOWN_OPENCODE_EVENT_TYPES = new Set<z.infer<typeof OpencodeEventSchema>[' 'server.connected', 'session.status', 'session.idle', + 'session.created', ]) type FetchLike = typeof fetch @@ -92,6 +126,13 @@ type MonitorState = { reconnectDelayMs: number reconnectTimer?: ReturnType<typeof setTimeout> reconnectResolve?: () => void + ownership: OpencodeOwnershipState + lastSnapshot?: { + cycleId: number + streamId: number + statuses: Record<string, z.infer<typeof SessionStatusSchema>> + at: number + } } function createAbortError(): Error { @@ -148,30 +189,27 @@ function parseOpencodeEvent(data: string): z.infer<typeof OpencodeEventSchema> | return parsedEvent.data } -function extractBusySessionId( - snapshot: Record<string, z.infer<typeof SessionStatusSchema>>, - currentSessionId?: string, -): string | undefined { - const busySessionIds = Object.entries(snapshot) - .filter(([, status]) => status.type !== 'idle') - .map(([sessionId]) => sessionId) - .sort() - if (busySessionIds.length === 0) return undefined - if (currentSessionId && busySessionIds.includes(currentSessionId)) { - return currentSessionId - } - return busySessionIds[0] -} +const defaultResolveOpencodeSessionRoots = async ( + sessionIds: readonly string[], +): Promise<OpencodeRootResolution> => ({ + rootsBySessionId: new Map(sessionIds.map((sessionId) => [sessionId, sessionId])), + unresolvedSessionIds: new Set<string>(), +}) export class OpencodeActivityTracker extends EventEmitter { private readonly records = new Map<string, OpencodeActivityRecord>() private readonly monitors = new Map<string, MonitorState>() + private readonly childSessionIds = new Map<string, Set<string>>() + private readonly sessionRootsByTerminal = new Map<string, Map<string, string>>() private readonly fetchImpl: FetchLike private readonly log: TrackerLogger private readonly now: () => number private readonly setTimeoutFn: typeof setTimeout private readonly clearTimeoutFn: typeof clearTimeout private readonly random: () => number + private readonly resolveOpencodeSessionRoots: (sessionIds: readonly string[]) => Promise<OpencodeRootResolution> + private nextCycleId = 0 + private nextStreamId = 0 constructor(input: { fetchImpl?: FetchLike @@ -180,6 +218,8 @@ export class OpencodeActivityTracker extends EventEmitter { setTimeoutFn?: typeof setTimeout clearTimeoutFn?: typeof clearTimeout random?: () => number + homeDir?: string + resolveOpencodeSessionRoots?: (sessionIds: readonly string[]) => Promise<OpencodeRootResolution> } = {}) { super() this.fetchImpl = input.fetchImpl ?? fetch @@ -188,6 +228,7 @@ export class OpencodeActivityTracker extends EventEmitter { this.setTimeoutFn = input.setTimeoutFn ?? setTimeout this.clearTimeoutFn = input.clearTimeoutFn ?? clearTimeout this.random = input.random ?? Math.random + this.resolveOpencodeSessionRoots = input.resolveOpencodeSessionRoots ?? defaultResolveOpencodeSessionRoots } list(): OpencodeActivityRecord[] { @@ -198,7 +239,7 @@ export class OpencodeActivityTracker extends EventEmitter { return this.records.get(terminalId) } - trackTerminal(input: { terminalId: string; endpoint: OpencodeServerEndpoint }): void { + trackTerminal(input: { terminalId: string; endpoint: OpencodeServerEndpoint; sessionId?: string }): void { const existing = this.monitors.get(input.terminalId) if ( existing @@ -206,6 +247,9 @@ export class OpencodeActivityTracker extends EventEmitter { && existing.endpoint.port === input.endpoint.port && !existing.disposed ) { + existing.ownership = createOpencodeOwnershipState(input.sessionId) + this.childSessionIds.delete(input.terminalId) + this.sessionRootsByTerminal.delete(input.terminalId) return } @@ -216,6 +260,7 @@ export class OpencodeActivityTracker extends EventEmitter { endpoint: input.endpoint, disposed: false, reconnectDelayMs: OPENCODE_RECONNECT_BASE_MS, + ownership: createOpencodeOwnershipState(input.sessionId), } this.monitors.set(input.terminalId, monitor) void this.runMonitor(monitor) @@ -225,6 +270,7 @@ export class OpencodeActivityTracker extends EventEmitter { const monitor = this.monitors.get(input.terminalId) if (monitor) { monitor.disposed = true + monitor.lastSnapshot = undefined monitor.controller?.abort() if (monitor.reconnectTimer) { this.clearTimeoutFn(monitor.reconnectTimer) @@ -234,6 +280,8 @@ export class OpencodeActivityTracker extends EventEmitter { monitor.reconnectResolve = undefined this.monitors.delete(input.terminalId) } + this.childSessionIds.delete(input.terminalId) + this.sessionRootsByTerminal.delete(input.terminalId) this.removeRecord(input.terminalId) } @@ -247,11 +295,11 @@ export class OpencodeActivityTracker extends EventEmitter { while (!monitor.disposed) { const controller = new AbortController() monitor.controller = controller + const cycleId = ++this.nextCycleId try { await this.waitForHealth(monitor, controller.signal) - await this.refreshSnapshot(monitor, controller.signal) monitor.reconnectDelayMs = OPENCODE_RECONNECT_BASE_MS - await this.consumeEvents(monitor, controller.signal) + await this.consumeEvents(monitor, cycleId, controller.signal) } catch (error) { if (monitor.disposed || isAbortError(error)) { return @@ -294,7 +342,12 @@ export class OpencodeActivityTracker extends EventEmitter { } } - private async refreshSnapshot(monitor: MonitorState, signal: AbortSignal): Promise<void> { + private async refreshSnapshot( + monitor: MonitorState, + cycleId: number, + streamId: number, + signal: AbortSignal, + ): Promise<void> { const response = await this.fetchImpl(this.buildUrl(monitor.endpoint, '/session/status'), { signal, }) @@ -307,22 +360,28 @@ export class OpencodeActivityTracker extends EventEmitter { throw new Error('OpenCode session status response did not match the expected schema.') } - const current = this.records.get(monitor.terminalId) - const busySessionId = extractBusySessionId(parsed.data, current?.sessionId) - if (!busySessionId) { - this.removeRecord(monitor.terminalId) + const at = this.now() + const classified = await this.classifySnapshotStatuses(monitor, parsed.data) + monitor.lastSnapshot = { cycleId, streamId, statuses: classified.statuses, at } + this.warnIfMultipleActiveRoots(monitor.terminalId, classified.statuses, classified.unresolvedSessionIds) + if (classified.unresolvedSessionIds.size > 0) { + this.upsertRecord({ + terminalId: monitor.terminalId, + phase: 'busy', + updatedAt: at, + }) return } - - this.upsertRecord({ - terminalId: monitor.terminalId, - sessionId: busySessionId, - phase: 'busy', - updatedAt: this.now(), + this.observe(monitor, { + kind: 'snapshot', + cycleId, + streamId, + statuses: classified.statuses, + at, }) } - private async consumeEvents(monitor: MonitorState, signal: AbortSignal): Promise<void> { + private async consumeEvents(monitor: MonitorState, cycleId: number, signal: AbortSignal): Promise<void> { const response = await this.fetchImpl(this.buildUrl(monitor.endpoint, '/event'), { signal, headers: { accept: 'text/event-stream' }, @@ -335,6 +394,8 @@ export class OpencodeActivityTracker extends EventEmitter { const decoder = new TextDecoder() const abortPromise = createAbortPromise(signal) let buffer = '' + let connected = false + const streamId = ++this.nextStreamId try { while (true) { @@ -355,7 +416,13 @@ export class OpencodeActivityTracker extends EventEmitter { while (separatorIndex >= 0) { const block = buffer.slice(0, separatorIndex) buffer = buffer.slice(separatorIndex + 2) - this.handleSseBlock(monitor.terminalId, block) + const event = this.parseSseBlock(monitor.terminalId, block) + if (event?.type === 'server.connected' && !connected) { + connected = true + await this.refreshSnapshot(monitor, cycleId, streamId, signal) + } else if (event && event.type !== 'server.connected') { + await this.handleOpencodeEvent(monitor, cycleId, streamId, event) + } separatorIndex = buffer.indexOf('\n\n') } } @@ -368,9 +435,12 @@ export class OpencodeActivityTracker extends EventEmitter { } } - private handleSseBlock(terminalId: string, block: string): void { + private parseSseBlock( + terminalId: string, + block: string, + ): z.infer<typeof OpencodeEventSchema> | undefined { const data = parseSseData(block) - if (!data) return + if (!data) return undefined let event: z.infer<typeof OpencodeEventSchema> | undefined try { @@ -382,28 +452,156 @@ export class OpencodeActivityTracker extends EventEmitter { endpoint, err: error, }, 'OpenCode event payload was invalid; skipping payload.') + return undefined + } + + return event + } + + private async handleOpencodeEvent( + monitor: MonitorState, + cycleId: number, + streamId: number, + event: Exclude<z.infer<typeof OpencodeEventSchema>, { type: 'server.connected' }>, + ): Promise<void> { + if (event.type === 'session.created') { + const parentId = event.properties.info.parentID + if (parentId) { + this.registerChildSession(monitor.terminalId, event.properties.sessionID, parentId) + if (monitor.lastSnapshot && monitor.ownership.kind === 'ambiguous') { + monitor.ownership = { ...monitor.ownership, knownSessionId: parentId } + this.observe(monitor, { + kind: 'snapshot', + cycleId: monitor.lastSnapshot.cycleId, + streamId: monitor.lastSnapshot.streamId, + statuses: this.classifyKnownSnapshotStatuses(monitor.terminalId, monitor.lastSnapshot.statuses), + at: monitor.lastSnapshot.at, + }) + } + } return } - if (!event) return - if (event.type === 'server.connected') return - if (event.type === 'session.idle') { - this.removeRecordForSession(terminalId, event.properties.sessionID) + const observedSessionId = await this.resolveRootForEvent(monitor, event.properties.sessionID) + const observedStatus = event.type === 'session.idle' + ? 'idle' + : event.properties.status.type + + if (observedStatus === 'idle') { + this.observe(monitor, { + kind: 'sse', + cycleId, + streamId, + sessionId: observedSessionId ?? event.properties.sessionID, + status: 'idle', + at: this.now(), + }) return } - if (event.properties.status.type === 'idle') { - this.removeRecordForSession(terminalId, event.properties.sessionID) + + if (!observedSessionId) { + this.upsertRecord({ + terminalId: monitor.terminalId, + phase: 'busy', + updatedAt: this.now(), + }) return } - this.upsertRecord({ - terminalId, - sessionId: event.properties.sessionID, - phase: 'busy', - updatedAt: this.now(), + this.observe(monitor, { + kind: 'sse', + cycleId, + streamId, + sessionId: observedSessionId, + status: observedStatus, + at: this.now(), }) } + private async resolveRootForEvent( + monitor: MonitorState, + sessionId: string, + ): Promise<string | undefined> { + const knownRoot = this.resolveKnownRoot(monitor.terminalId, sessionId) + if (knownRoot) return knownRoot + + try { + const resolved = await this.resolveOpencodeSessionRoots([sessionId]) + for (const [resolvedSessionId, rootSessionId] of resolved.rootsBySessionId) { + this.registerSessionRoot(monitor.terminalId, resolvedSessionId, rootSessionId) + } + return resolved.rootsBySessionId.get(sessionId) + } catch (err) { + this.log.warn({ + err, + terminalId: monitor.terminalId, + sessionId, + }, 'Failed to resolve OpenCode root session for activity event') + return undefined + } + } + + confirmSessionAssociation(input: { terminalId: string; sessionId: string }): void { + const monitor = this.monitors.get(input.terminalId) + if (!monitor || monitor.disposed) return + const result = confirmOpencodeAssociation(monitor.ownership, { sessionId: input.sessionId }) + monitor.ownership = result.state + this.applyActions(monitor.terminalId, result.actions) + } + + rejectSessionAssociation(input: { terminalId: string; sessionId: string }): void { + const monitor = this.monitors.get(input.terminalId) + if (!monitor || monitor.disposed) return + const result = rejectOpencodeAssociation(monitor.ownership, { sessionId: input.sessionId }) + monitor.ownership = result.state + this.applyActions(monitor.terminalId, result.actions) + } + + private observe(monitor: MonitorState, observation: OpencodeObservation): void { + const result = reduceOpencodeOwnership(monitor.ownership, observation) + monitor.ownership = result.state + this.applyActions(monitor.terminalId, result.actions) + } + + private applyActions(terminalId: string, actions: OpencodeOwnershipAction[]): void { + for (const action of actions) { + if (action.kind === 'activityUpsert') { + this.upsertRecord({ + terminalId, + ...(action.sessionId ? { sessionId: action.sessionId } : {}), + phase: 'busy', + updatedAt: action.at, + }) + continue + } + if (action.kind === 'activityRemove') { + this.removeRecord(terminalId) + continue + } + if (action.kind === 'requestAssociation') { + this.emit('association.requested', { + terminalId, + sessionId: action.sessionId, + } satisfies OpencodeAssociationRequestedEvent) + continue + } + if (action.kind === 'turnComplete') { + this.emit('turn.complete', { + terminalId, + sessionId: action.sessionId, + at: action.at, + } satisfies OpencodeTurnCompleteEvent) + continue + } + if (action.kind === 'warnAmbiguous') { + this.log.warn({ + terminalId, + sessionIds: action.sessionIds, + }, 'OpenCode endpoint reported ambiguous session ownership; suppressing durable adoption.') + } + } + } + private async sleepWithBackoff(monitor: MonitorState): Promise<void> { const baseDelay = monitor.reconnectDelayMs const jitter = Math.floor(baseDelay * 0.1 * this.random()) @@ -454,13 +652,6 @@ export class OpencodeActivityTracker extends EventEmitter { return `http://${endpoint.hostname}:${endpoint.port}${pathname}` } - private removeRecordForSession(terminalId: string, sessionId: string): void { - const existing = this.records.get(terminalId) - if (!existing) return - if (existing.sessionId && existing.sessionId !== sessionId) return - this.removeRecord(terminalId) - } - private upsertRecord(record: OpencodeActivityRecord): void { const previous = this.records.get(record.terminalId) if ( @@ -485,4 +676,114 @@ export class OpencodeActivityTracker extends EventEmitter { remove: [terminalId], } satisfies OpencodeActivityChange) } + + private registerChildSession(terminalId: string, sessionId: string, parentId: string): void { + let children = this.childSessionIds.get(terminalId) + if (!children) { + children = new Set() + this.childSessionIds.set(terminalId, children) + } + children.add(sessionId) + const rootSessionId = this.resolveKnownRoot(terminalId, parentId) ?? parentId + this.registerSessionRoot(terminalId, parentId, rootSessionId) + this.registerSessionRoot(terminalId, sessionId, rootSessionId) + } + + private registerSessionRoot(terminalId: string, sessionId: string, rootSessionId: string): void { + let roots = this.sessionRootsByTerminal.get(terminalId) + if (!roots) { + roots = new Map() + this.sessionRootsByTerminal.set(terminalId, roots) + } + roots.set(sessionId, rootSessionId) + roots.set(rootSessionId, rootSessionId) + } + + private resolveKnownRoot(terminalId: string, sessionId: string): string | undefined { + const roots = this.sessionRootsByTerminal.get(terminalId) + if (!roots) return undefined + let current = sessionId + const seen = new Set<string>() + while (true) { + if (seen.has(current)) return undefined + seen.add(current) + const next = roots.get(current) + if (!next) return current === sessionId ? undefined : current + if (next === current) return current + current = next + } + } + + private warnIfMultipleActiveRoots( + terminalId: string, + statuses: Record<string, z.infer<typeof SessionStatusSchema>>, + unresolvedSessionIds: Set<string>, + ): void { + const activeSessionIds = Object.entries(statuses) + .filter(([, status]) => status.type !== 'idle') + .map(([sessionId]) => sessionId) + .sort() + const rootSessionIds = activeSessionIds.filter((sessionId) => !unresolvedSessionIds.has(sessionId)) + const unresolvedActiveSessionIds = activeSessionIds.filter((sessionId) => unresolvedSessionIds.has(sessionId)) + if (rootSessionIds.length + unresolvedActiveSessionIds.length <= 1) return + + this.log.warn({ + terminalId, + rootSessionIds, + unresolvedSessionIds: unresolvedActiveSessionIds, + }, 'OpenCode reported multiple active root sessions; leaving terminal activity unbound.') + } + + private async classifySnapshotStatuses( + monitor: MonitorState, + statuses: Record<string, z.infer<typeof SessionStatusSchema>>, + ): Promise<{ + statuses: Record<string, z.infer<typeof SessionStatusSchema>> + unresolvedSessionIds: Set<string> + }> { + const activeSessionIds = Object.entries(statuses) + .filter(([, status]) => status.type !== 'idle') + .map(([sessionId]) => sessionId) + const unresolvedCandidates = activeSessionIds.filter( + (sessionId) => !this.resolveKnownRoot(monitor.terminalId, sessionId), + ) + + let unresolvedSessionIds = new Set<string>() + if (unresolvedCandidates.length > 0) { + try { + const resolution = await this.resolveOpencodeSessionRoots(unresolvedCandidates) + for (const [sessionId, rootSessionId] of resolution.rootsBySessionId) { + this.registerSessionRoot(monitor.terminalId, sessionId, rootSessionId) + } + unresolvedSessionIds = resolution.unresolvedSessionIds + } catch (err) { + this.log.warn({ + err, + terminalId: monitor.terminalId, + sessionIds: unresolvedCandidates, + }, 'Failed to resolve OpenCode root sessions before activity classification') + unresolvedSessionIds = new Set(unresolvedCandidates) + } + } + + return { + statuses: this.classifyKnownSnapshotStatuses(monitor.terminalId, statuses), + unresolvedSessionIds, + } + } + + private classifyKnownSnapshotStatuses( + terminalId: string, + statuses: Record<string, z.infer<typeof SessionStatusSchema>>, + ): Record<string, z.infer<typeof SessionStatusSchema>> { + const classified: Record<string, z.infer<typeof SessionStatusSchema>> = {} + for (const [sessionId, status] of Object.entries(statuses)) { + const rootSessionId = this.resolveKnownRoot(terminalId, sessionId) ?? sessionId + const existing = classified[rootSessionId] + if (!existing || existing.type === 'idle' || status.type !== 'idle') { + classified[rootSessionId] = status + } + } + return classified + } } diff --git a/server/coding-cli/opencode-activity-wiring.ts b/server/coding-cli/opencode-activity-wiring.ts index aa475235a..f809bb066 100644 --- a/server/coding-cli/opencode-activity-wiring.ts +++ b/server/coding-cli/opencode-activity-wiring.ts @@ -1,8 +1,14 @@ import { OpencodeActivityTracker } from './opencode-activity-tracker.js' +import type { + OpencodeActivityChange, + OpencodeTurnCompleteEvent, +} from './opencode-activity-tracker.js' import { OpencodeSessionController } from './opencode-session-controller.js' +import type { OpencodeRootResolution } from './providers/opencode.js' import type { OpencodeServerEndpoint } from '../local-port.js' import type { BindSessionResult, TerminalRecord } from '../terminal-registry.js' import type { SessionBindingReason } from '../terminal-stream/registry-events.js' +import type { OpencodeSessionAssociatedEvent } from './opencode-session-controller.js' type OpencodeActivityRegistry = { list: () => Array<{ terminalId: string }> @@ -13,12 +19,6 @@ type OpencodeActivityRegistry = { sessionId: string, reason?: SessionBindingReason, ) => BindSessionResult - rebindSession: ( - terminalId: string, - provider: 'opencode', - sessionId: string, - reason?: SessionBindingReason, - ) => BindSessionResult on: (event: string, handler: (...args: any[]) => void) => void off: (event: string, handler: (...args: any[]) => void) => void } @@ -34,6 +34,10 @@ export function wireOpencodeActivityTracker(input: { setTimeoutFn?: typeof setTimeout clearTimeoutFn?: typeof clearTimeout random?: () => number + resolveOpencodeSessionRoots?: (sessionIds: readonly string[]) => Promise<OpencodeRootResolution> + onActivityChanged?: (payload: OpencodeActivityChange) => void + onAssociated?: (payload: OpencodeSessionAssociatedEvent) => void + onTurnComplete?: (payload: OpencodeTurnCompleteEvent) => void }) { const tracker = new OpencodeActivityTracker({ fetchImpl: input.fetchImpl, @@ -41,11 +45,21 @@ export function wireOpencodeActivityTracker(input: { setTimeoutFn: input.setTimeoutFn, clearTimeoutFn: input.clearTimeoutFn, random: input.random, + resolveOpencodeSessionRoots: input.resolveOpencodeSessionRoots, }) + if (input.onActivityChanged) { + tracker.on('changed', input.onActivityChanged) + } + if (input.onTurnComplete) { + tracker.on('turn.complete', input.onTurnComplete) + } const controller = new OpencodeSessionController({ tracker, registry: input.registry, }) + if (input.onAssociated) { + controller.on('associated', input.onAssociated) + } const startTracking = (record: TerminalRecord) => { const endpoint = getEndpoint(record) @@ -53,6 +67,7 @@ export function wireOpencodeActivityTracker(input: { tracker.trackTerminal({ terminalId: record.terminalId, endpoint, + sessionId: record.resumeSessionId, }) } @@ -80,6 +95,15 @@ export function wireOpencodeActivityTracker(input: { dispose(): void { input.registry.off('terminal.created', onCreated) input.registry.off('terminal.exit', onExit) + if (input.onActivityChanged) { + tracker.off('changed', input.onActivityChanged) + } + if (input.onTurnComplete) { + tracker.off('turn.complete', input.onTurnComplete) + } + if (input.onAssociated) { + controller.off('associated', input.onAssociated) + } controller.dispose() tracker.dispose() }, diff --git a/server/coding-cli/opencode-ownership-reducer.ts b/server/coding-cli/opencode-ownership-reducer.ts new file mode 100644 index 000000000..d7ee6e227 --- /dev/null +++ b/server/coding-cli/opencode-ownership-reducer.ts @@ -0,0 +1,461 @@ +export type OpencodeSessionStatusType = 'idle' | 'busy' | 'retry' + +export type OpencodeSessionStatus = { + type: OpencodeSessionStatusType +} + +export type OpencodeObservation = + | { + kind: 'snapshot' + cycleId: number + streamId: number + statuses: Record<string, OpencodeSessionStatus> + at: number + } + | { + kind: 'sse' + cycleId: number + streamId: number + sessionId: string + status: OpencodeSessionStatusType + at: number + } + +export type OpencodeOwnershipState = + | { + kind: 'quiet' + knownSessionId?: string + } + | { + kind: 'candidate' + sessionId: string + previousKnownSessionId?: string + startedBy: 'snapshot' | 'sse' + cycleId: number + streamId: number + } + | { + kind: 'knownBusy' + sessionId: string + startedBy: 'snapshot' | 'sse' + cycleId: number + streamId: number + } + | { + kind: 'awaitingAssociation' + sessionId: string + previousKnownSessionId?: string + cycleId: number + streamId: number + completedAt: number + } + | { + kind: 'ambiguous' + knownSessionId?: string + blockedSessionIds: string[] + since: number + } + +export type OpencodeOwnershipAction = + | { + kind: 'activityUpsert' + sessionId?: string + at: number + } + | { + kind: 'activityRemove' + at: number + } + | { + kind: 'requestAssociation' + sessionId: string + } + | { + kind: 'turnComplete' + sessionId: string + at: number + } + | { + kind: 'warnAmbiguous' + sessionIds: string[] + } + +export type OpencodeOwnershipResult = { + state: OpencodeOwnershipState + actions: OpencodeOwnershipAction[] +} + +export function createOpencodeOwnershipState(knownSessionId?: string): OpencodeOwnershipState { + return { kind: 'quiet', knownSessionId } +} + +function sortedBusySessionIds(statuses: Record<string, OpencodeSessionStatus>): string[] { + return Object.entries(statuses) + .filter(([, status]) => status.type !== 'idle') + .map(([sessionId]) => sessionId) + .sort() +} + +function uniqueSorted(values: string[]): string[] { + return Array.from(new Set(values)).sort() +} + +function sameSessionStream( + state: Extract<OpencodeOwnershipState, { kind: 'candidate' | 'knownBusy' }>, + observation: Extract<OpencodeObservation, { kind: 'sse' }>, +): boolean { + return state.sessionId === observation.sessionId + && state.cycleId === observation.cycleId + && state.streamId === observation.streamId +} + +function enterAmbiguous(input: { + knownSessionId?: string + blockedSessionIds: string[] + at: number +}): OpencodeOwnershipResult { + const blockedSessionIds = uniqueSorted(input.blockedSessionIds) + return { + state: { + kind: 'ambiguous', + knownSessionId: input.knownSessionId, + blockedSessionIds, + since: input.at, + }, + actions: [ + { kind: 'activityUpsert', at: input.at }, + { kind: 'warnAmbiguous', sessionIds: blockedSessionIds }, + ], + } +} + +function reduceBusy( + state: OpencodeOwnershipState, + observation: Extract<OpencodeObservation, { kind: 'sse' }>, +): OpencodeOwnershipResult { + const nextBusyState = { + sessionId: observation.sessionId, + startedBy: 'sse' as const, + cycleId: observation.cycleId, + streamId: observation.streamId, + } + + if (state.kind === 'quiet') { + if (state.knownSessionId === observation.sessionId) { + return { + state: { kind: 'knownBusy', ...nextBusyState }, + actions: [{ kind: 'activityUpsert', sessionId: observation.sessionId, at: observation.at }], + } + } + return { + state: { + kind: 'candidate', + previousKnownSessionId: state.knownSessionId, + ...nextBusyState, + }, + actions: [{ kind: 'activityUpsert', sessionId: observation.sessionId, at: observation.at }], + } + } + + if (state.kind === 'candidate') { + if (state.sessionId === observation.sessionId) { + return { + state: { ...state, cycleId: observation.cycleId, streamId: observation.streamId, startedBy: 'sse' }, + actions: [{ kind: 'activityUpsert', sessionId: observation.sessionId, at: observation.at }], + } + } + return enterAmbiguous({ + knownSessionId: state.previousKnownSessionId, + blockedSessionIds: [state.sessionId, observation.sessionId], + at: observation.at, + }) + } + + if (state.kind === 'knownBusy') { + if (state.sessionId === observation.sessionId) { + return { + state: { ...state, cycleId: observation.cycleId, streamId: observation.streamId, startedBy: 'sse' }, + actions: [{ kind: 'activityUpsert', sessionId: observation.sessionId, at: observation.at }], + } + } + return enterAmbiguous({ + knownSessionId: state.sessionId, + blockedSessionIds: [state.sessionId, observation.sessionId], + at: observation.at, + }) + } + + if (state.kind === 'ambiguous') { + if (state.blockedSessionIds.includes(observation.sessionId)) { + return { + state, + actions: [{ kind: 'activityUpsert', at: observation.at }], + } + } + const blockedSessionIds = uniqueSorted([...state.blockedSessionIds, observation.sessionId]) + return { + state: { ...state, blockedSessionIds }, + actions: [ + { kind: 'activityUpsert', at: observation.at }, + { kind: 'warnAmbiguous', sessionIds: blockedSessionIds }, + ], + } + } + + return { state, actions: [] } +} + +function reduceIdle( + state: OpencodeOwnershipState, + observation: Extract<OpencodeObservation, { kind: 'sse' }>, +): OpencodeOwnershipResult { + if (state.kind === 'candidate') { + if (!sameSessionStream(state, observation)) return { state, actions: [] } + return { + state: { + kind: 'awaitingAssociation', + sessionId: state.sessionId, + previousKnownSessionId: state.previousKnownSessionId, + cycleId: state.cycleId, + streamId: state.streamId, + completedAt: observation.at, + }, + actions: [ + { kind: 'activityRemove', at: observation.at }, + { kind: 'requestAssociation', sessionId: state.sessionId }, + ], + } + } + + if (state.kind === 'knownBusy') { + if (!sameSessionStream(state, observation)) return { state, actions: [] } + return { + state: { + kind: 'quiet', + knownSessionId: state.sessionId, + }, + actions: [ + { kind: 'activityRemove', at: observation.at }, + { kind: 'turnComplete', sessionId: state.sessionId, at: observation.at }, + ], + } + } + + if (state.kind === 'ambiguous') { + if (!state.blockedSessionIds.includes(observation.sessionId)) { + return { state, actions: [] } + } + + const blockedSessionIds = state.blockedSessionIds.filter( + (sessionId) => sessionId !== observation.sessionId, + ) + if (blockedSessionIds.length === 0) { + return { + state: { kind: 'quiet', knownSessionId: state.knownSessionId }, + actions: [{ kind: 'activityRemove', at: observation.at }], + } + } + + return { + state: { ...state, blockedSessionIds }, + actions: [{ kind: 'activityUpsert', at: observation.at }], + } + } + + return { state, actions: [] } +} + +function reduceSnapshot( + state: OpencodeOwnershipState, + observation: Extract<OpencodeObservation, { kind: 'snapshot' }>, +): OpencodeOwnershipResult { + const busySessionIds = sortedBusySessionIds(observation.statuses) + + if (state.kind === 'ambiguous') { + if (busySessionIds.length === 0) { + return { + state: { kind: 'quiet', knownSessionId: state.knownSessionId }, + actions: [{ kind: 'activityRemove', at: observation.at }], + } + } + const blockedSessionIds = uniqueSorted(busySessionIds) + if (blockedSessionIds.length === 1) { + const singleSessionId = blockedSessionIds[0] + if (state.knownSessionId && singleSessionId === state.knownSessionId) { + return { + state: { + kind: 'knownBusy', + sessionId: state.knownSessionId, + startedBy: 'snapshot', + cycleId: observation.cycleId, + streamId: observation.streamId, + }, + actions: [{ kind: 'activityUpsert', sessionId: state.knownSessionId, at: observation.at }], + } + } + if (!state.knownSessionId) { + return { + state: { + kind: 'candidate', + sessionId: singleSessionId, + startedBy: 'snapshot', + cycleId: observation.cycleId, + streamId: observation.streamId, + }, + actions: [], + } + } + } + const changed = blockedSessionIds.length !== state.blockedSessionIds.length + || blockedSessionIds.some((id, i) => id !== state.blockedSessionIds[i]) + return { + state: { ...state, blockedSessionIds }, + actions: changed + ? [ + { kind: 'activityUpsert', at: observation.at }, + { kind: 'warnAmbiguous', sessionIds: blockedSessionIds }, + ] + : [{ kind: 'activityUpsert', at: observation.at }], + } + } + + if (state.kind === 'knownBusy') { + if (busySessionIds.length === 0) { + return { + state: { kind: 'quiet', knownSessionId: state.sessionId }, + actions: [{ kind: 'activityRemove', at: observation.at }], + } + } + if (busySessionIds.length === 1 && busySessionIds[0] === state.sessionId) { + return { + state: { ...state, startedBy: 'snapshot', cycleId: observation.cycleId, streamId: observation.streamId }, + actions: [{ kind: 'activityUpsert', sessionId: state.sessionId, at: observation.at }], + } + } + return enterAmbiguous({ + knownSessionId: state.sessionId, + blockedSessionIds: uniqueSorted([state.sessionId, ...busySessionIds]), + at: observation.at, + }) + } + + if (state.kind === 'candidate') { + if (busySessionIds.length === 0) { + return { + state: { kind: 'quiet', knownSessionId: state.previousKnownSessionId }, + actions: [{ kind: 'activityRemove', at: observation.at }], + } + } + if (busySessionIds.length === 1 && busySessionIds[0] === state.sessionId) { + return { + state: { ...state, startedBy: 'snapshot', cycleId: observation.cycleId, streamId: observation.streamId }, + actions: [{ kind: 'activityUpsert', sessionId: state.sessionId, at: observation.at }], + } + } + return enterAmbiguous({ + knownSessionId: state.previousKnownSessionId, + blockedSessionIds: uniqueSorted([state.sessionId, ...busySessionIds]), + at: observation.at, + }) + } + + if (state.kind === 'awaitingAssociation') { + return { state, actions: [] } + } + + if (busySessionIds.length === 0) { + return { + state, + actions: [{ kind: 'activityRemove', at: observation.at }], + } + } + + if (state.knownSessionId && busySessionIds.includes(state.knownSessionId)) { + if (busySessionIds.length === 1) { + return { + state: { + kind: 'knownBusy', + sessionId: state.knownSessionId, + startedBy: 'snapshot', + cycleId: observation.cycleId, + streamId: observation.streamId, + }, + actions: [{ kind: 'activityUpsert', sessionId: state.knownSessionId, at: observation.at }], + } + } + return enterAmbiguous({ + knownSessionId: state.knownSessionId, + blockedSessionIds: busySessionIds, + at: observation.at, + }) + } + + if (busySessionIds.length === 1) { + return { + state: { + kind: 'candidate', + previousKnownSessionId: state.knownSessionId, + sessionId: busySessionIds[0], + startedBy: 'snapshot', + cycleId: observation.cycleId, + streamId: observation.streamId, + }, + actions: [{ kind: 'activityUpsert', sessionId: busySessionIds[0], at: observation.at }], + } + } + + return enterAmbiguous({ + knownSessionId: state.knownSessionId, + blockedSessionIds: busySessionIds, + at: observation.at, + }) +} + +export function reduceOpencodeOwnership( + state: OpencodeOwnershipState, + observation: OpencodeObservation, +): OpencodeOwnershipResult { + if (observation.kind === 'snapshot') { + return reduceSnapshot(state, observation) + } + if (observation.status === 'idle') { + return reduceIdle(state, observation) + } + return reduceBusy(state, observation) +} + +export function confirmOpencodeAssociation( + state: OpencodeOwnershipState, + input: { sessionId: string }, +): OpencodeOwnershipResult { + if (state.kind !== 'awaitingAssociation' || state.sessionId !== input.sessionId) { + return { state, actions: [] } + } + return { + state: { + kind: 'quiet', + knownSessionId: state.sessionId, + }, + actions: [{ + kind: 'turnComplete', + sessionId: state.sessionId, + at: state.completedAt, + }], + } +} + +export function rejectOpencodeAssociation( + state: OpencodeOwnershipState, + input: { sessionId: string }, +): OpencodeOwnershipResult { + if (state.kind !== 'awaitingAssociation' || state.sessionId !== input.sessionId) { + return { state, actions: [] } + } + return { + state: { + kind: 'quiet', + knownSessionId: state.previousKnownSessionId, + }, + actions: [], + } +} diff --git a/server/coding-cli/opencode-session-controller.ts b/server/coding-cli/opencode-session-controller.ts index e9afe1451..108f24aa0 100644 --- a/server/coding-cli/opencode-session-controller.ts +++ b/server/coding-cli/opencode-session-controller.ts @@ -3,14 +3,14 @@ import type { SessionBindingReason } from '../terminal-stream/registry-events.js import type { BindSessionResult, TerminalRecord } from '../terminal-registry.js' import { logger } from '../logger.js' import type { - OpencodeActivityChange, - OpencodeActivityRecord, + OpencodeAssociationRequestedEvent, } from './opencode-activity-tracker.js' type OpencodeActivityTrackerLike = { - list: () => OpencodeActivityRecord[] - on: (event: 'changed', handler: (payload: OpencodeActivityChange) => void) => void - off: (event: 'changed', handler: (payload: OpencodeActivityChange) => void) => void + confirmSessionAssociation: (input: { terminalId: string; sessionId: string }) => void + rejectSessionAssociation: (input: { terminalId: string; sessionId: string }) => void + on: (event: 'association.requested', handler: (payload: OpencodeAssociationRequestedEvent) => void) => void + off: (event: 'association.requested', handler: (payload: OpencodeAssociationRequestedEvent) => void) => void } type OpencodeSessionRegistry = { @@ -21,12 +21,6 @@ type OpencodeSessionRegistry = { sessionId: string, reason?: SessionBindingReason, ) => BindSessionResult - rebindSession?: ( - terminalId: string, - provider: 'opencode', - sessionId: string, - reason?: SessionBindingReason, - ) => BindSessionResult on: (event: 'terminal.exit', handler: (payload: { terminalId?: string }) => void) => void off: (event: 'terminal.exit', handler: (payload: { terminalId?: string }) => void) => void } @@ -46,10 +40,8 @@ export class OpencodeSessionController extends EventEmitter { private readonly log: ControllerLogger private readonly associatedSessionIds = new Map<string, string>() - private readonly handleTrackerChanged = (payload: OpencodeActivityChange) => { - for (const record of payload.upsert) { - this.promoteRecord(record) - } + private readonly handleAssociationRequested = (payload: OpencodeAssociationRequestedEvent) => { + this.promoteAssociation(payload) } private readonly handleTerminalExit = (payload: { terminalId?: string }) => { @@ -67,56 +59,70 @@ export class OpencodeSessionController extends EventEmitter { this.registry = input.registry this.log = input.log ?? logger.child({ component: 'opencode-session-controller' }) - this.tracker.on('changed', this.handleTrackerChanged) + this.tracker.on('association.requested', this.handleAssociationRequested) this.registry.on('terminal.exit', this.handleTerminalExit) - - const existing = this.tracker.list() - if (existing.length > 0) { - this.handleTrackerChanged({ - upsert: existing, - remove: [], - }) - } } dispose(): void { - this.tracker.off('changed', this.handleTrackerChanged) + this.tracker.off('association.requested', this.handleAssociationRequested) this.registry.off('terminal.exit', this.handleTerminalExit) this.associatedSessionIds.clear() } - private promoteRecord(record: OpencodeActivityRecord): void { - if (!record.sessionId) return + private rejectAssociation( + request: OpencodeAssociationRequestedEvent, + reason: string, + extra: Record<string, unknown> = {}, + ): void { + this.log.warn({ + terminalId: request.terminalId, + sessionId: request.sessionId, + reason, + ...extra, + }, 'Rejected OpenCode association request') + } - const terminal = this.registry.get(record.terminalId) - if (!terminal || terminal.mode !== 'opencode' || terminal.status !== 'running') { + private promoteAssociation(request: OpencodeAssociationRequestedEvent): void { + const terminal = this.registry.get(request.terminalId) + if (!terminal) { + this.rejectAssociation(request, 'terminal_missing_or_not_running') + this.tracker.rejectSessionAssociation(request) + return + } + if (terminal.mode !== 'opencode') { + this.rejectAssociation(request, 'terminal_not_opencode', { mode: terminal.mode }) + this.tracker.rejectSessionAssociation(request) + return + } + if (terminal.status !== 'running') { + this.rejectAssociation(request, 'terminal_missing_or_not_running', { status: terminal.status }) + this.tracker.rejectSessionAssociation(request) return } - const previousSessionId = this.associatedSessionIds.get(record.terminalId) ?? terminal.resumeSessionId - if (previousSessionId === record.sessionId) { - this.associatedSessionIds.set(record.terminalId, record.sessionId) + const previousSessionId = this.associatedSessionIds.get(request.terminalId) ?? terminal.resumeSessionId + if (previousSessionId === request.sessionId) { + this.associatedSessionIds.set(request.terminalId, request.sessionId) + this.tracker.confirmSessionAssociation(request) return } - const bind = previousSessionId && this.registry.rebindSession - ? this.registry.rebindSession.bind(this.registry) - : this.registry.bindSession.bind(this.registry) - const result = bind(record.terminalId, 'opencode', record.sessionId, 'association') + const result = this.registry.bindSession(request.terminalId, 'opencode', request.sessionId, 'association') if (!result.ok) { - this.log.warn({ - terminalId: record.terminalId, - sessionId: record.sessionId, - reason: result.reason, - }, 'Failed to promote OpenCode durable session from authoritative control data') + this.rejectAssociation(request, result.reason, { + ...(previousSessionId ? { previousSessionId } : {}), + ...('owner' in result ? { ownerTerminalId: result.owner } : {}), + }) + this.tracker.rejectSessionAssociation(request) return } - this.associatedSessionIds.set(record.terminalId, record.sessionId) + this.associatedSessionIds.set(request.terminalId, request.sessionId) this.emit('associated', { - terminalId: record.terminalId, - sessionId: record.sessionId, + terminalId: request.terminalId, + sessionId: request.sessionId, } satisfies OpencodeSessionAssociatedEvent) + this.tracker.confirmSessionAssociation(request) } } diff --git a/server/coding-cli/providers/opencode.ts b/server/coding-cli/providers/opencode.ts index c1a2c64c7..c3a89d087 100644 --- a/server/coding-cli/providers/opencode.ts +++ b/server/coding-cli/providers/opencode.ts @@ -15,7 +15,27 @@ type OpencodeSessionRow = { projectPath: string | null } -function defaultOpencodeDataHome(): string { +type OpencodeSessionSchema = { + hasParentId: boolean +} + +type OpencodeDatabaseMessageClass = + | 'missing_db' + | 'empty_db' + | 'sqlite_unavailable' + | 'sqlite_open_failed' + | 'schema_error' + | 'read_error' + | 'schema_missing_parent_id' + +type OpencodeDatabaseLogLevel = 'debug' | 'info' | 'warn' + +export type OpencodeRootResolution = { + rootsBySessionId: Map<string, string> + unresolvedSessionIds: Set<string> +} + +export function defaultOpencodeDataHome(): string { if (process.env.XDG_DATA_HOME) { return path.join(process.env.XDG_DATA_HOME, 'opencode') } @@ -33,6 +53,8 @@ function toValidTimestamp(value: unknown): number | undefined { export class OpencodeProvider implements CodingCliProvider { readonly name = 'opencode' as const readonly displayName = 'OpenCode' + private sessionSchemaCache?: OpencodeSessionSchema + private readonly loggedDatabaseStates = new Set<string>() constructor(readonly homeDir: string = defaultOpencodeDataHome()) {} @@ -40,25 +62,83 @@ export class OpencodeProvider implements CodingCliProvider { return path.join(this.homeDir, 'opencode.db') } + private getWatchedDatabasePaths(): [string, string] { + const dbPath = this.getDatabasePath() + return [dbPath, `${dbPath}-wal`] + } + + private databaseLogFields( + messageClass: OpencodeDatabaseMessageClass, + error?: unknown, + extra?: Record<string, unknown>, + ): Record<string, unknown> { + return { + provider: this.name, + dbPathLabel: '<opencode-data>/opencode.db', + dbFile: 'opencode.db', + pathSanitized: true, + messageClass, + ...(error instanceof Error ? { errorName: error.name } : {}), + ...(extra ?? {}), + } + } + + private logDatabaseStateOnce( + level: OpencodeDatabaseLogLevel, + messageClass: OpencodeDatabaseMessageClass, + message: string, + options: { + scope?: string + error?: unknown + extra?: Record<string, unknown> + } = {}, + ): void { + const key = `${options.scope ?? 'default'}:${messageClass}:${level}` + if (this.loggedDatabaseStates.has(key)) return + this.loggedDatabaseStates.add(key) + logger[level](this.databaseLogFields(messageClass, options.error, options.extra), message) + } + + private classifyDatabaseFailure(phase: 'open' | 'schema' | 'query' | 'map'): OpencodeDatabaseMessageClass { + switch (phase) { + case 'open': + return 'sqlite_open_failed' + case 'schema': + return 'schema_error' + case 'query': + case 'map': + return 'read_error' + } + } + async listSessionsDirect(): Promise<CodingCliSession[]> { const dbPath = this.getDatabasePath() try { await fsp.access(dbPath) } catch { + this.logDatabaseStateOnce('info', 'missing_db', 'OpenCode sessions database is not available') return [] } let sqlite: typeof import('node:sqlite') try { sqlite = await import('node:sqlite') - } catch { - logger.warn({ provider: this.name, nodeVersion: process.version }, 'node:sqlite unavailable — OpenCode sessions will not appear. Upgrade to Node 22.5+ to enable.') + } catch (err) { + this.logDatabaseStateOnce('warn', 'sqlite_unavailable', 'node:sqlite unavailable — OpenCode sessions will not appear. Upgrade to Node 22.5+ to enable.', { + error: err, + extra: { nodeVersion: process.version }, + }) return [] } let db: InstanceType<typeof sqlite.DatabaseSync> | undefined + let phase: 'open' | 'schema' | 'query' | 'map' = 'open' try { db = new sqlite.DatabaseSync(dbPath, { readOnly: true }) + phase = 'schema' + const schema = this.inspectSessionSchema(db) + const rootFilter = schema.hasParentId ? 'AND s.parent_id IS NULL' : '' + phase = 'query' const rows = db.prepare(` SELECT s.id AS sessionId, @@ -70,11 +150,18 @@ export class OpencodeProvider implements CodingCliProvider { FROM session s LEFT JOIN project p ON p.id = s.project_id - WHERE s.parent_id IS NULL - AND s.time_archived IS NULL + WHERE s.time_archived IS NULL + ${rootFilter} ORDER BY s.time_updated DESC `).all() as OpencodeSessionRow[] + if (rows.length === 0) { + this.logDatabaseStateOnce('info', 'empty_db', 'OpenCode sessions database has no active root sessions', { + extra: { rowCount: 0 }, + }) + } + + phase = 'map' const sessions: CodingCliSession[] = [] for (const row of rows) { if (typeof row.cwd !== 'string' || !row.cwd) continue @@ -91,15 +178,136 @@ export class OpencodeProvider implements CodingCliProvider { } return sessions } catch (err) { - logger.warn({ err, dbPath, provider: this.name }, 'Failed to read OpenCode sessions database') + this.logDatabaseStateOnce('warn', this.classifyDatabaseFailure(phase), 'Failed to read OpenCode sessions database', { + error: err, + }) return [] } finally { db?.close() } } + async resolveOpencodeSessionRoots(sessionIds: readonly string[]): Promise<OpencodeRootResolution> { + const requestedIds = Array.from(new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0))) + const rootsBySessionId = new Map<string, string>() + const unresolvedSessionIds = new Set<string>() + if (requestedIds.length === 0) { + return { rootsBySessionId, unresolvedSessionIds } + } + + const dbPath = this.getDatabasePath() + try { + await fsp.access(dbPath) + } catch { + for (const id of requestedIds) unresolvedSessionIds.add(id) + this.logDatabaseStateOnce('debug', 'missing_db', 'OpenCode database missing during root resolution', { + scope: 'resolve', + extra: { requestedSessionCount: requestedIds.length }, + }) + return { rootsBySessionId, unresolvedSessionIds } + } + + let sqlite: typeof import('node:sqlite') + try { + sqlite = await import('node:sqlite') + } catch (err) { + this.logDatabaseStateOnce('warn', 'sqlite_unavailable', 'node:sqlite unavailable while resolving OpenCode roots', { + scope: 'resolve', + error: err, + extra: { + nodeVersion: process.version, + requestedSessionCount: requestedIds.length, + }, + }) + for (const id of requestedIds) unresolvedSessionIds.add(id) + return { rootsBySessionId, unresolvedSessionIds } + } + + let db: InstanceType<typeof sqlite.DatabaseSync> | undefined + let phase: 'open' | 'schema' | 'query' | 'map' = 'open' + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }) + phase = 'schema' + const schema = this.inspectSessionSchema(db) + if (!schema.hasParentId) { + for (const id of requestedIds) rootsBySessionId.set(id, id) + return { rootsBySessionId, unresolvedSessionIds } + } + + const rowsById = new Map<string, string | null>() + let pending = requestedIds + phase = 'query' + while (pending.length > 0) { + const placeholders = pending.map(() => '?').join(',') + const rows = db.prepare( + `SELECT id, parent_id FROM session WHERE id IN (${placeholders})`, + ).all(...pending) as Array<{ id: string; parent_id: string | null }> + const nextPending: string[] = [] + for (const row of rows) { + if (typeof row.id !== 'string') continue + rowsById.set(row.id, typeof row.parent_id === 'string' ? row.parent_id : null) + if (row.parent_id && !rowsById.has(row.parent_id)) { + nextPending.push(row.parent_id) + } + } + pending = Array.from(new Set(nextPending)) + } + + phase = 'map' + for (const requestedId of requestedIds) { + let current: string | null | undefined = requestedId + const seen = new Set<string>() + while (current) { + if (seen.has(current)) { + unresolvedSessionIds.add(requestedId) + break + } + seen.add(current) + if (!rowsById.has(current)) { + unresolvedSessionIds.add(requestedId) + break + } + const parentId = rowsById.get(current) + if (!parentId) { + rootsBySessionId.set(requestedId, current) + break + } + current = parentId + } + } + return { rootsBySessionId, unresolvedSessionIds } + } catch (err) { + this.logDatabaseStateOnce('warn', this.classifyDatabaseFailure(phase), 'Failed to resolve OpenCode root sessions', { + scope: 'resolve', + error: err, + extra: { requestedSessionCount: requestedIds.length }, + }) + for (const id of requestedIds) unresolvedSessionIds.add(id) + return { rootsBySessionId, unresolvedSessionIds } + } finally { + db?.close() + } + } + + private inspectSessionSchema(db: { prepare: (sql: string) => { all: (...args: any[]) => unknown[] } }): OpencodeSessionSchema { + if (this.sessionSchemaCache) return this.sessionSchemaCache + const rows = db.prepare('PRAGMA table_info(session)').all() + const columnNames = new Set(rows + .map((row) => (row as { name?: unknown }).name) + .filter((name): name is string => typeof name === 'string')) + const schema = { + hasParentId: columnNames.has('parent_id'), + } + if (!schema.hasParentId) { + this.logDatabaseStateOnce('warn', 'schema_missing_parent_id', 'OpenCode session schema does not expose parent_id; treating sessions as flat roots') + } + this.sessionSchemaCache = schema + return schema + } + getSessionGlob(): string { - return this.getDatabasePath() + const [dbPath, walPath] = this.getWatchedDatabasePaths() + return `{${dbPath},${walPath}}` } getSessionRoots(): string[] { diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index 72f676cf8..4b7a86e89 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -622,13 +622,36 @@ export class CodingCliSessionIndexer { return match } + scheduleProviderRefresh( + providerName: CodingCliProviderName, + options: { urgent?: boolean; reason?: string } = {}, + ): void { + const provider = this.providers.find((candidate) => candidate.name === providerName) + if (!provider?.listSessionsDirect) { + logger.warn( + { provider: providerName, reason: options.reason }, + 'Ignoring provider refresh for non-direct provider', + ) + return + } + this.dirtyProviders.add(providerName) + if (options.urgent) { + this.urgentRefreshNeeded = true + } + logger.debug({ + provider: providerName, + reason: options.reason, + urgent: !!options.urgent, + }, 'Scheduled provider refresh') + this.scheduleRefresh() + } + private markDirty(filePath: string) { const normalized = normalizeFilePath(filePath) const provider = this.resolveProviderForFile(filePath) if (provider?.listSessionsDirect) { this.dirtyProviders.add(provider.name) this.deletedFiles.delete(normalized) - this.urgentRefreshNeeded = true return } this.deletedFiles.delete(normalized) @@ -919,7 +942,7 @@ export class CodingCliSessionIndexer { ? (this.throttleMs > 0 ? Math.min(URGENT_THROTTLE_MS, this.throttleMs) : 0) : this.throttleMs const fireElapsed = Date.now() - this.lastRefreshAt - if (effectiveThrottle > 0 && fireElapsed < effectiveThrottle) { + if (this.lastRefreshAt > 0 && effectiveThrottle > 0 && fireElapsed < effectiveThrottle) { this.refreshTimer = setTimeout(() => { this.refreshTimer = null this.refresh().catch((err) => logger.warn({ err }, 'Refresh failed')) diff --git a/server/config-store.ts b/server/config-store.ts index 1e475bbf5..bd98b01bc 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -285,6 +285,49 @@ function migrateLegacyFreshClaudeSettings(rawSettings: Record<string, unknown>): return migrated } +function normalizeFreshAgentCompatSettings(rawSettings: Record<string, unknown>): Record<string, unknown> { + const freshAgent = isRecord(rawSettings.freshAgent) ? rawSettings.freshAgent : undefined + const agentChat = isRecord(rawSettings.agentChat) ? rawSettings.agentChat : undefined + + if (!freshAgent && !agentChat) { + return rawSettings + } + + const merged: Record<string, unknown> = { + ...(agentChat || {}), + ...(freshAgent || {}), + } + + const freshPlugins = Array.isArray(freshAgent?.defaultPlugins) ? freshAgent.defaultPlugins : undefined + const agentPlugins = Array.isArray(agentChat?.defaultPlugins) ? agentChat.defaultPlugins : undefined + if ((freshPlugins?.length ?? 0) > 0) { + merged.defaultPlugins = freshPlugins + } else if (agentPlugins) { + merged.defaultPlugins = agentPlugins + } + + const freshProviders = isRecord(freshAgent?.providers) ? freshAgent.providers : undefined + const agentProviders = isRecord(agentChat?.providers) ? agentChat.providers : undefined + if (freshProviders || agentProviders) { + merged.providers = { + ...(agentProviders || {}), + ...(freshProviders || {}), + } + } + + if (typeof freshAgent?.initialSetupDone === 'boolean') { + merged.initialSetupDone = freshAgent.initialSetupDone + } else if (typeof agentChat?.initialSetupDone === 'boolean') { + merged.initialSetupDone = agentChat.initialSetupDone + } + + return { + ...rawSettings, + freshAgent: merged, + agentChat: merged, + } +} + export class ConfigStore { private cache: UserConfig | null = null private writeMutex = new Mutex() @@ -310,9 +353,9 @@ export class ConfigStore { this.lastReadError = error if (existing) { this.lastReadError = undefined - const rawSettings = migrateLegacyFreshClaudeSettings( + const rawSettings = normalizeFreshAgentCompatSettings(migrateLegacyFreshClaudeSettings( isRecord(existing.settings) ? { ...existing.settings } : {}, - ) + )) const extractedLegacyLocalSettingsSeed = extractLegacyLocalSettingsSeed(rawSettings) const storedLegacyLocalSettingsSeed = isRecord(existing.legacyLocalSettingsSeed) ? extractLegacyLocalSettingsSeed(existing.legacyLocalSettingsSeed) diff --git a/server/fresh-agent/adapters/claude/adapter.ts b/server/fresh-agent/adapters/claude/adapter.ts new file mode 100644 index 000000000..8b396f1d1 --- /dev/null +++ b/server/fresh-agent/adapters/claude/adapter.ts @@ -0,0 +1,212 @@ +import { + RestoreResolutionError, + RestoreStaleRevisionError, + createAgentTimelineService, + type AgentTimelineService, +} from '../../../agent-timeline/service.js' +import type { AgentHistorySource } from '../../../agent-timeline/history-source.js' +import type { SdkBridge } from '../../../sdk-bridge.js' +import type { SdkSessionState } from '../../../sdk-bridge-types.js' +import { FreshAgentStaleThreadRevisionError } from '../../runtime-manager.js' +import type { FreshAgentCreateRequest, FreshAgentRuntimeAdapter, FreshAgentThreadLocator } from '../../runtime-adapter.js' +import { + normalizeClaudeThreadSnapshot, + normalizeClaudeTurnBody, + normalizeClaudeTurnPage, +} from './normalize.js' + +type ClaudeBridgePort = Pick< + SdkBridge, + | 'createSession' + | 'subscribe' + | 'sendUserMessage' + | 'interrupt' + | 'killSession' + | 'respondQuestion' + | 'respondPermission' + | 'getSession' + | 'findSessionByCliSessionId' +> + +export type ClaudeFreshAgentAdapterDeps = { + sdkBridge: ClaudeBridgePort + agentHistorySource?: AgentHistorySource + timelineService?: AgentTimelineService +} + +function mapTimelineError(error: unknown): never { + if (error instanceof RestoreStaleRevisionError) { + throw new FreshAgentStaleThreadRevisionError(error.actualRevision) + } + throw error +} + +function toClaudeEffort(value: FreshAgentCreateRequest['effort']) { + if (value === undefined || value === 'low' || value === 'medium' || value === 'high' || value === 'max') { + return value + } + throw new Error(`Freshclaude does not support reasoning effort "${value}".`) +} + +function mapMissingResult(ok: boolean, message: string): void { + if (!ok) { + throw new Error(message) + } +} + +export function createClaudeFreshAgentAdapter(deps: ClaudeFreshAgentAdapterDeps): FreshAgentRuntimeAdapter { + const timelineService = deps.timelineService ?? ( + deps.agentHistorySource + ? createAgentTimelineService({ agentHistorySource: deps.agentHistorySource }) + : null + ) + + function resolveLiveSession(threadId: string): SdkSessionState | undefined { + return deps.sdkBridge.getSession(threadId) ?? deps.sdkBridge.findSessionByCliSessionId(threadId) + } + + async function loadResolved(threadId: string, revision?: number) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + return await timelineService.getSnapshot({ sessionId: threadId, revision }) + } catch (error) { + mapTimelineError(error) + } + } + + return { + runtimeProvider: 'claude', + + async create(input: FreshAgentCreateRequest) { + const session = await deps.sdkBridge.createSession({ + cwd: input.cwd, + resumeSessionId: input.resumeSessionId, + model: input.model, + permissionMode: input.permissionMode, + effort: toClaudeEffort(input.effort), + plugins: input.plugins, + }) + return { sessionId: session.sessionId } + }, + + async resume(input: FreshAgentCreateRequest) { + const session = await deps.sdkBridge.createSession({ + cwd: input.cwd, + resumeSessionId: input.resumeSessionId, + model: input.model, + permissionMode: input.permissionMode, + effort: toClaudeEffort(input.effort), + plugins: input.plugins, + }) + return { sessionId: session.sessionId } + }, + + subscribe(sessionId, listener) { + const subscription = deps.sdkBridge.subscribe(sessionId, listener as never) + if (!subscription) { + throw new Error(`Claude session ${sessionId} is not available`) + } + return subscription.off + }, + + send(sessionId, input) { + const images = input.images?.flatMap((image) => image.kind === 'data' + ? [{ mediaType: image.mediaType, data: image.data }] + : []) + mapMissingResult( + deps.sdkBridge.sendUserMessage(sessionId, input.text, images), + `Claude session ${sessionId} is not available`, + ) + }, + + interrupt(sessionId) { + mapMissingResult( + deps.sdkBridge.interrupt(sessionId), + `Claude session ${sessionId} is not available`, + ) + }, + + kill(sessionId) { + return deps.sdkBridge.killSession(sessionId) + }, + + answerQuestion(sessionId, requestId, answers) { + mapMissingResult( + deps.sdkBridge.respondQuestion(sessionId, String(requestId), answers), + `Claude question ${requestId} is not available`, + ) + }, + + resolveApproval(sessionId, requestId, decision) { + mapMissingResult( + deps.sdkBridge.respondPermission(sessionId, String(requestId), decision as never), + `Claude approval ${requestId} is not available`, + ) + }, + + async getSnapshot(thread: FreshAgentThreadLocator, revision?: number) { + const resolvedSnapshot = await loadResolved(thread.threadId, revision) + const liveSession = resolveLiveSession(thread.threadId) + const resolved = await deps.agentHistorySource?.resolve( + thread.threadId, + liveSession ? { liveSessionOverride: liveSession } : undefined, + ) + if (!resolved || resolved.kind !== 'resolved') { + throw new RestoreResolutionError('RESTORE_NOT_FOUND', 'Restore session not found') + } + return normalizeClaudeThreadSnapshot({ + threadId: thread.threadId, + resolved: { + ...resolved, + revision: resolvedSnapshot.revision, + latestTurnId: resolvedSnapshot.latestTurnId, + turns: resolvedSnapshot.turns, + }, + liveSession, + status: liveSession?.status ?? 'idle', + }) + }, + + async getTurnPage(thread, query) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + const page = await timelineService.getTimelinePage({ + sessionId: thread.threadId, + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + priority: typeof query.priority === 'string' ? query.priority as 'visible' | 'background' : undefined, + revision: Number(query.revision), + limit: typeof query.limit === 'number' ? query.limit : undefined, + includeBodies: query.includeBodies === true, + }) + return normalizeClaudeTurnPage({ threadId: thread.threadId, page }) + } catch (error) { + mapTimelineError(error) + } + }, + + async getTurnBody(thread, revision) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + const turn = await timelineService.getTurnBody({ + sessionId: thread.threadId, + turnId: thread.turnId, + revision, + }) + if (!turn) return null + return normalizeClaudeTurnBody({ + turn, + revision, + threadId: thread.threadId, + }) + } catch (error) { + mapTimelineError(error) + } + }, + } +} diff --git a/server/fresh-agent/adapters/claude/normalize.ts b/server/fresh-agent/adapters/claude/normalize.ts new file mode 100644 index 000000000..a8ed0560f --- /dev/null +++ b/server/fresh-agent/adapters/claude/normalize.ts @@ -0,0 +1,251 @@ +import type { RestoreResolution } from '../../../agent-timeline/ledger.js' +import type { AgentTimelinePage, AgentTimelineTurn } from '../../../agent-timeline/types.js' +import type { QuestionDefinition, SdkSessionState } from '../../../sdk-bridge-types.js' +import type { SdkSessionStatus } from '../../../../shared/ws-protocol.js' +import type { ContentBlock } from '../../../../shared/ws-protocol.js' +import { + FreshAgentSnapshotSchema, + FreshAgentTurnBodySchema, + FreshAgentTurnPageSchema, +} from '../../../../shared/fresh-agent-contract.js' + +export type FreshAgentNormalizedItem = + | { id: string; kind: 'text'; text: string } + | { id: string; kind: 'thinking'; text: string } + | { id: string; kind: 'tool_use'; toolUseId: string; name: string; input?: Record<string, unknown> } + | { id: string; kind: 'tool_result'; toolUseId: string; content: unknown; isError: boolean } + +export type FreshAgentNormalizedTurn = { + id: string + turnId: string + messageId: string + ordinal: number + source: 'durable' | 'live' + role: 'user' | 'assistant' + timestamp?: string + model?: string + summary: string + items: FreshAgentNormalizedItem[] +} + +export type FreshAgentPendingApproval = { + requestId: string + toolName: string + toolUseID?: string + blockedPath?: string + decisionReason?: string + input?: Record<string, unknown> +} + +export type FreshAgentPendingQuestion = { + requestId: string + questions: QuestionDefinition[] +} + +export type FreshAgentClaudeSnapshot = { + provider: 'claude' + threadId: string + sessionId: string + revision: number + latestTurnId: string | null + status: SdkSessionStatus + capabilities: { + send: boolean + interrupt: boolean + approvals: boolean + questions: boolean + fork: boolean + } + settings: { + model?: string + permissionMode?: string + plugins: string[] + } + tokenUsage: { + inputTokens: number + outputTokens: number + totalTokens: number + costUsd: number + } + pendingApprovals: FreshAgentPendingApproval[] + pendingQuestions: FreshAgentPendingQuestion[] + turns: FreshAgentNormalizedTurn[] + extensions: { + claude: { + timelineSessionId?: string + liveSessionId?: string + cliSessionId?: string + readiness?: RestoreResolution extends infer T ? T extends { kind: 'resolved'; readiness: infer R } ? R : never : never + } + } +} + +export type FreshAgentClaudeTurnPage = { + threadId: string + revision: number + nextCursor: string | null + turns: FreshAgentNormalizedTurn[] + bodies?: Record<string, FreshAgentNormalizedTurn> +} + +function blockSummary(blocks: ContentBlock[]): string { + const textBlock = blocks.find((block) => block.type === 'text' && block.text.trim().length > 0) + if (textBlock?.type === 'text') { + return textBlock.text.trim().slice(0, 140) + } + const thinkingBlock = blocks.find((block) => block.type === 'thinking' && block.thinking.trim().length > 0) + if (thinkingBlock?.type === 'thinking') { + return thinkingBlock.thinking.trim().slice(0, 140) + } + const toolBlock = blocks.find((block) => block.type === 'tool_use') + if (toolBlock?.type === 'tool_use') { + return toolBlock.name.slice(0, 140) + } + return '' +} + +export function normalizeClaudeTurn(input: Pick<AgentTimelineTurn, 'turnId' | 'messageId' | 'ordinal' | 'source' | 'message'>): FreshAgentNormalizedTurn { + return { + id: input.turnId, + turnId: input.turnId, + messageId: input.messageId, + ordinal: input.ordinal, + source: input.source, + role: input.message.role, + ...(input.message.timestamp ? { timestamp: input.message.timestamp } : {}), + ...(input.message.model ? { model: input.message.model } : {}), + summary: blockSummary(input.message.content), + items: input.message.content.map((block, index) => { + const id = `${input.turnId}:item:${index}` + switch (block.type) { + case 'text': + return { id, kind: 'text', text: block.text } + case 'thinking': + return { id, kind: 'thinking', text: block.thinking } + case 'tool_use': + return { id, kind: 'tool_use', toolUseId: block.id, name: block.name, input: block.input } + case 'tool_result': + return { + id, + kind: 'tool_result', + toolUseId: block.tool_use_id, + content: block.content, + isError: Boolean(block.is_error), + } + } + }), + } +} + +function normalizePendingApprovals(liveSession?: SdkSessionState): FreshAgentPendingApproval[] { + if (!liveSession) return [] + return Array.from(liveSession.pendingPermissions.entries()).map(([requestId, approval]) => ({ + requestId, + toolName: approval.toolName, + toolUseID: approval.toolUseID, + blockedPath: approval.blockedPath, + decisionReason: approval.decisionReason, + input: approval.input, + })) +} + +function normalizePendingQuestions(liveSession?: SdkSessionState): FreshAgentPendingQuestion[] { + if (!liveSession) return [] + return Array.from(liveSession.pendingQuestions.entries()).map(([requestId, question]) => ({ + requestId, + questions: question.questions, + })) +} + +export function normalizeClaudeThreadSnapshot(input: { + threadId: string + resolved: Extract<RestoreResolution, { kind: 'resolved' }> + liveSession?: SdkSessionState + status: SdkSessionStatus +}): FreshAgentClaudeSnapshot { + const sessionId = input.liveSession?.sessionId ?? input.resolved.liveSessionId ?? input.threadId + const turns = input.resolved.turns.map((turn) => normalizeClaudeTurn(turn)) + const inputTokens = input.liveSession?.totalInputTokens ?? 0 + const outputTokens = input.liveSession?.totalOutputTokens ?? 0 + return FreshAgentSnapshotSchema.parse({ + sessionType: 'freshclaude', + provider: 'claude', + threadId: input.threadId, + sessionId, + revision: input.resolved.revision, + latestTurnId: input.resolved.latestTurnId, + status: input.status, + capabilities: { + send: true, + interrupt: input.status !== 'exited', + approvals: normalizePendingApprovals(input.liveSession).length > 0, + questions: normalizePendingQuestions(input.liveSession).length > 0, + fork: false, + }, + settings: { + ...(input.liveSession?.model ? { model: input.liveSession.model } : {}), + ...(input.liveSession?.permissionMode ? { permissionMode: input.liveSession.permissionMode } : {}), + plugins: input.liveSession?.plugins ? [...input.liveSession.plugins] : [], + }, + tokenUsage: { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + costUsd: input.liveSession?.costUsd ?? 0, + }, + pendingApprovals: normalizePendingApprovals(input.liveSession), + pendingQuestions: normalizePendingQuestions(input.liveSession), + turns, + extensions: { + claude: { + timelineSessionId: input.resolved.timelineSessionId, + liveSessionId: input.resolved.liveSessionId, + cliSessionId: input.liveSession?.cliSessionId, + readiness: input.resolved.readiness, + }, + }, + }) as FreshAgentClaudeSnapshot +} + +export function normalizeClaudeTurnPage(input: { + threadId: string + page: AgentTimelinePage +}): FreshAgentClaudeTurnPage { + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshclaude', + provider: 'claude', + threadId: input.threadId, + revision: input.page.revision, + nextCursor: input.page.nextCursor, + turns: input.page.items.map((item) => ({ + id: item.turnId, + turnId: item.turnId, + messageId: item.messageId, + ordinal: item.ordinal, + source: item.source, + role: item.role, + ...(item.timestamp ? { timestamp: item.timestamp } : {}), + summary: item.summary, + items: [], + })), + ...(input.page.bodies ? { + bodies: Object.fromEntries( + Object.entries(input.page.bodies).map(([turnId, turn]) => [turnId, normalizeClaudeTurn(turn)]), + ), + } : {}), + }) as FreshAgentClaudeTurnPage +} + +export function normalizeClaudeTurnBody(input: { + turn: AgentTimelineTurn + revision: number + threadId: string +}) { + return FreshAgentTurnBodySchema.parse({ + ...normalizeClaudeTurn(input.turn), + sessionType: 'freshclaude', + provider: 'claude', + threadId: input.threadId, + revision: input.revision, + }) +} diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts new file mode 100644 index 000000000..d1e893b25 --- /dev/null +++ b/server/fresh-agent/adapters/codex/adapter.ts @@ -0,0 +1,292 @@ +import type { FreshAgentCreateRequest, FreshAgentInputImage, FreshAgentRuntimeAdapter } from '../../runtime-adapter.js' +import type { + CodexThreadForkParams, + CodexTurnInterruptParams, + CodexTurnStartParams, +} from '../../../coding-cli/codex-app-server/protocol.js' +import { + normalizeCodexThreadSnapshot, + normalizeCodexTurn, + normalizeCodexTurnBody, + normalizeCodexTurnPage, +} from './normalize.js' + +type CodexThreadLifecycleEvent = + | { + kind: 'thread_started' + thread: { + id: string + updatedAt?: number + status?: unknown + } + } + | { + kind: 'thread_closed' + threadId: string + } + | { + kind: 'thread_status_changed' + threadId: string + status: unknown + } + +type CodexRuntimePort = { + startThread: (input: { + cwd?: string + model?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never' + excludeTurns?: boolean + }) => Promise<{ threadId: string; wsUrl: string }> + resumeThread: (input: { + threadId: string + cwd?: string + model?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never' + }) => Promise<{ threadId: string; wsUrl: string }> + forkThread?: (input: CodexThreadForkParams) => Promise<{ threadId: string; wsUrl: string }> + startTurn?: (input: CodexTurnStartParams) => Promise<{ turnId: string }> + interruptTurn?: (input: CodexTurnInterruptParams) => Promise<void> + onThreadLifecycle?: (handler: (event: CodexThreadLifecycleEvent) => void) => () => void + readThread: (input: { threadId: string; includeTurns?: boolean }) => Promise<Record<string, any>> + listThreadTurns: (input: { + threadId: string + cursor?: string + limit?: number + }) => Promise<Record<string, any>> + readThreadTurn: (input: { threadId: string; turnId: string; revision?: number }) => Promise<Record<string, any>> +} + +function toCodexApprovalPolicy(value: string | undefined) { + if (value === undefined) return undefined + if (value === 'untrusted' || value === 'on-failure' || value === 'on-request' || value === 'never') { + return value + } + throw new Error(`Freshcodex does not support approval policy "${value}". Choose untrusted, on-failure, on-request, or never.`) +} + +function toCodexReasoningEffort(value: FreshAgentCreateRequest['effort'] | undefined) { + if (value === undefined) return undefined + if (value === 'none' || value === 'minimal' || value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh') { + return value + } + throw new Error(`Freshcodex does not support reasoning effort "${value}". Choose none, minimal, low, medium, high, or xhigh.`) +} + +function toCodexSandboxPolicy(value: FreshAgentCreateRequest['sandbox'] | undefined): CodexTurnStartParams['sandboxPolicy'] { + switch (value) { + case undefined: + return undefined + case 'danger-full-access': + return { type: 'dangerFullAccess' } + case 'read-only': + return { type: 'readOnly' } + case 'workspace-write': + return { type: 'workspaceWrite' } + default: + throw new Error(`Freshcodex does not support sandbox "${String(value)}".`) + } +} + +function toCodexUserInput(text: string, images: FreshAgentInputImage[] | undefined): CodexTurnStartParams['input'] { + const input: CodexTurnStartParams['input'] = [{ + type: 'text', + text, + text_elements: [], + }] + for (const image of images ?? []) { + if (image.kind === 'url') { + input.push({ type: 'image', url: image.url }) + } else if (image.kind === 'local') { + input.push({ type: 'localImage', path: image.path }) + } else { + input.push({ type: 'image', url: `data:${image.mediaType};base64,${image.data}` }) + } + } + return input +} + +function normalizeCodexThreadStatus(status: unknown): string { + if (!status || typeof status !== 'object') return 'idle' + const type = (status as { type?: unknown }).type + if (type === 'active') return 'running' + if (type === 'notLoaded') return 'starting' + if (type === 'systemError') return 'exited' + if (type === 'idle') return 'idle' + return 'idle' +} + +function makeCodexStatusEvent(sessionId: string, status: unknown, revision?: number) { + return { + type: 'sdk.session.snapshot', + sessionId, + latestTurnId: null, + status: normalizeCodexThreadStatus(status), + timelineSessionId: sessionId, + revision, + } +} + +export function createCodexFreshAgentAdapter(deps: { + runtime: CodexRuntimePort +}): FreshAgentRuntimeAdapter { + const activeTurnByThread = new Map<string, string>() + const settingsByThread = new Map<string, FreshAgentCreateRequest>() + + return { + runtimeProvider: 'codex', + + async create(input: FreshAgentCreateRequest) { + toCodexReasoningEffort(input.effort) + const started = await deps.runtime.startThread({ + cwd: input.cwd, + model: input.model, + sandbox: input.sandbox, + approvalPolicy: toCodexApprovalPolicy(input.permissionMode), + excludeTurns: true, + }) + settingsByThread.set(started.threadId, input) + return { sessionId: started.threadId, sessionRef: { provider: 'codex', sessionId: started.threadId } } + }, + + async resume(input: FreshAgentCreateRequest) { + if (!input.resumeSessionId) { + throw new Error('Codex rich resume requires resumeSessionId') + } + toCodexReasoningEffort(input.effort) + const resumed = await deps.runtime.resumeThread({ + threadId: input.resumeSessionId, + cwd: input.cwd, + model: input.model, + sandbox: input.sandbox, + approvalPolicy: toCodexApprovalPolicy(input.permissionMode), + }) + settingsByThread.set(resumed.threadId, input) + return { sessionId: resumed.threadId, sessionRef: { provider: 'codex', sessionId: resumed.threadId } } + }, + + subscribe(sessionId, listener) { + if (!deps.runtime.onThreadLifecycle) { + throw new Error('Codex app-server runtime does not support thread lifecycle subscriptions.') + } + return deps.runtime.onThreadLifecycle((event) => { + if (event.kind === 'thread_started') { + if (event.thread.id !== sessionId) return + listener(makeCodexStatusEvent(sessionId, event.thread.status, event.thread.updatedAt)) + return + } + if (event.kind === 'thread_closed') { + if (event.threadId !== sessionId) return + activeTurnByThread.delete(sessionId) + listener({ + type: 'sdk.status', + sessionId, + status: 'exited', + }) + return + } + if (event.threadId !== sessionId) return + const status = normalizeCodexThreadStatus(event.status) + if (status !== 'running' && status !== 'starting') { + activeTurnByThread.delete(sessionId) + } + listener(makeCodexStatusEvent(sessionId, event.status)) + }) + }, + + async send(sessionId, input) { + if (!deps.runtime.startTurn) { + throw new Error('Codex app-server runtime does not support turn/start.') + } + const settings = { + ...settingsByThread.get(sessionId), + ...input.settings, + } + const turn = await deps.runtime.startTurn({ + threadId: sessionId, + input: toCodexUserInput(input.text, input.images), + cwd: settings.cwd, + approvalPolicy: toCodexApprovalPolicy(settings.permissionMode), + sandboxPolicy: toCodexSandboxPolicy(settings.sandbox), + model: settings.model, + effort: toCodexReasoningEffort(settings.effort), + }) + activeTurnByThread.set(sessionId, turn.turnId) + }, + + async interrupt(sessionId) { + if (!deps.runtime.interruptTurn) { + throw new Error('Codex app-server runtime does not support turn/interrupt.') + } + const turnId = activeTurnByThread.get(sessionId) + if (!turnId) { + throw new Error(`No active Codex turn is tracked for ${sessionId}.`) + } + await deps.runtime.interruptTurn({ threadId: sessionId, turnId }) + activeTurnByThread.delete(sessionId) + }, + + async fork(sessionId, input) { + if (!deps.runtime.forkThread) { + throw new Error('Codex app-server runtime does not support thread/fork.') + } + const settings = settingsByThread.get(sessionId) + return await deps.runtime.forkThread({ + threadId: sessionId, + cwd: typeof input?.cwd === 'string' ? input.cwd : settings?.cwd, + model: typeof input?.model === 'string' ? input.model : settings?.model, + sandbox: typeof input?.sandbox === 'string' ? input.sandbox as FreshAgentCreateRequest['sandbox'] : settings?.sandbox, + approvalPolicy: toCodexApprovalPolicy( + typeof input?.permissionMode === 'string' ? input.permissionMode : settings?.permissionMode, + ), + excludeTurns: true, + }) + }, + + async getSnapshot(thread, revision) { + const rawSnapshot = await deps.runtime.readThread({ threadId: thread.threadId, includeTurns: true }) + const rawThreadTurns: unknown[] = Array.isArray(rawSnapshot.thread?.turns) + ? rawSnapshot.thread.turns + : [] + const rawTurns = rawThreadTurns + .filter((turn): turn is Record<string, unknown> => !!turn && typeof turn === 'object' && !Array.isArray(turn)) + .map((turn, index) => normalizeCodexTurn(turn, index)) + return normalizeCodexThreadSnapshot({ + threadId: thread.threadId, + revision: Number(rawSnapshot.thread?.updatedAt ?? revision ?? 0), + status: normalizeCodexThreadStatus(rawSnapshot.thread?.status), + transcript: { + turns: rawTurns, + }, + rawSnapshot, + }) + }, + + async getTurnPage(thread, query) { + const rawPage = await deps.runtime.listThreadTurns({ + threadId: thread.threadId, + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + limit: typeof query.limit === 'number' ? query.limit : undefined, + }) + return normalizeCodexTurnPage({ + threadId: thread.threadId, + revision: Number(rawPage.revision ?? query.revision ?? 0), + rawPage, + }) + }, + + async getTurnBody(thread, revision) { + const rawTurn = await deps.runtime.readThreadTurn({ + threadId: thread.threadId, + turnId: thread.turnId, + revision, + }) + return normalizeCodexTurnBody({ + threadId: thread.threadId, + revision, + rawTurn, + }) + }, + } +} diff --git a/server/fresh-agent/adapters/codex/normalize.ts b/server/fresh-agent/adapters/codex/normalize.ts new file mode 100644 index 000000000..d9a5496d8 --- /dev/null +++ b/server/fresh-agent/adapters/codex/normalize.ts @@ -0,0 +1,251 @@ +import { + FreshAgentSnapshotSchema, + FreshAgentTurnBodySchema, + FreshAgentTurnPageSchema, + type FreshAgentTranscriptItem, + type FreshAgentTurn, +} from '../../../../shared/fresh-agent-contract.js' + +type CodexRawSnapshot = { + thread?: { + preview?: string + turns?: unknown[] + } + summary?: string + tokenUsage?: { + inputTokens: number + outputTokens: number + cachedTokens?: number + totalTokens: number + contextTokens?: number + compactPercent?: number + } + worktrees?: Array<{ id: string; path: string; branch?: string }> + diffs?: Array<{ id: string; path: string; title?: string }> + childThreads?: Array<{ id: string; threadId: string; origin: string; title?: string }> + extension?: { codex?: Record<string, unknown> } +} + +function normalizeCodexItem(turnId: string, item: Record<string, unknown>, index: number): FreshAgentTranscriptItem[] { + const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : `${turnId}:item:${index}` + switch (item.type) { + case 'userMessage': { + const content = Array.isArray(item.content) ? item.content : [] + if (content.length === 0) { + return [{ id, kind: 'text', text: '' }] + } + return content.map((part, partIndex) => { + const typedPart = part && typeof part === 'object' ? part as Record<string, unknown> : {} + if (typedPart.type === 'text') { + return { + id: `${id}:part:${partIndex}`, + kind: 'text' as const, + text: typeof typedPart.text === 'string' ? typedPart.text : '', + } + } + return { + id: `${id}:part:${partIndex}`, + kind: 'text' as const, + text: `[${String(typedPart.type ?? 'input')}]`, + } + }) + } + case 'agentMessage': + return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }] + case 'plan': + return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }] + case 'reasoning': { + const summary = Array.isArray(item.summary) ? item.summary.filter((value): value is string => typeof value === 'string') : [] + const content = Array.isArray(item.content) ? item.content.filter((value): value is string => typeof value === 'string') : [] + return [{ + id, + kind: 'reasoning', + summary, + content, + text: summary.join('\n') || content.join('\n'), + }] + } + case 'commandExecution': + return [{ + id, + kind: 'command', + command: typeof item.command === 'string' ? item.command : '', + cwd: typeof item.cwd === 'string' ? item.cwd : undefined, + status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed', + output: typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : null, + exitCode: typeof item.exitCode === 'number' ? item.exitCode : null, + extensions: { codex: item }, + }] + case 'fileChange': + return [{ + id, + kind: 'file_change', + status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed', + changes: Array.isArray(item.changes) + ? item.changes.filter((change): change is Record<string, unknown> => !!change && typeof change === 'object' && !Array.isArray(change)) + : [], + extensions: { codex: item }, + }] + case 'mcpToolCall': + return [{ + id, + kind: 'mcp_tool', + server: typeof item.server === 'string' ? item.server : '', + tool: typeof item.tool === 'string' ? item.tool : '', + status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', + arguments: item.arguments ?? null, + result: item.result, + error: item.error, + }] + case 'dynamicToolCall': + return [{ + id, + kind: 'dynamic_tool', + namespace: typeof item.namespace === 'string' ? item.namespace : null, + tool: typeof item.tool === 'string' ? item.tool : '', + status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', + arguments: item.arguments ?? null, + contentItems: Array.isArray(item.contentItems) ? item.contentItems : null, + success: typeof item.success === 'boolean' ? item.success : null, + }] + case 'collabAgentToolCall': + return [{ + id, + kind: 'collab_agent', + tool: String(item.tool ?? ''), + status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', + senderThreadId: String(item.senderThreadId ?? ''), + receiverThreadIds: Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string') + : [], + prompt: typeof item.prompt === 'string' ? item.prompt : null, + model: typeof item.model === 'string' ? item.model : null, + reasoningEffort: typeof item.reasoningEffort === 'string' ? item.reasoningEffort : null, + agentsStates: item.agentsStates && typeof item.agentsStates === 'object' && !Array.isArray(item.agentsStates) + ? item.agentsStates as Record<string, unknown> + : {}, + }] + case 'webSearch': + return [{ + id, + kind: 'web_search', + query: typeof item.query === 'string' ? item.query : '', + action: item.action ?? null, + }] + case 'imageView': + return [{ id, kind: 'image_view', path: typeof item.path === 'string' ? item.path : '' }] + case 'imageGeneration': + return [{ + id, + kind: 'image_generation', + status: String(item.status ?? ''), + revisedPrompt: typeof item.revisedPrompt === 'string' ? item.revisedPrompt : null, + result: String(item.result ?? ''), + savedPath: typeof item.savedPath === 'string' ? item.savedPath : undefined, + }] + case 'enteredReviewMode': + return [{ id, kind: 'review_mode', event: 'entered', review: String(item.review ?? '') }] + case 'exitedReviewMode': + return [{ id, kind: 'review_mode', event: 'exited', review: String(item.review ?? '') }] + case 'contextCompaction': + return [{ id, kind: 'context_compaction' }] + case 'hookPrompt': + return [{ id, kind: 'text', text: 'Hook prompt' }] + default: + throw new Error(`Unsupported Codex thread item type: ${String(item.type)}`) + } +} + +export function normalizeCodexTurn(rawTurn: Record<string, unknown>, ordinal = 0): FreshAgentTurn { + const turnId = String(rawTurn.id ?? `turn:${ordinal}`) + const rawItems = Array.isArray(rawTurn.items) + ? rawTurn.items.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item)) + : [] + const items = rawItems.flatMap((item, index) => normalizeCodexItem(turnId, item, index)) + const firstText = items.find((item): item is Extract<FreshAgentTranscriptItem, { kind: 'text' }> => item.kind === 'text') + return { + id: turnId, + turnId, + ordinal, + source: 'durable', + summary: firstText?.text.slice(0, 140) ?? '', + items, + } +} + +export function normalizeCodexTurnPage(input: { + threadId: string + revision: number + rawPage: { turns?: unknown[]; nextCursor?: string | null; backwardsCursor?: string | null } +}) { + const turns = (Array.isArray(input.rawPage.turns) ? input.rawPage.turns : []) + .filter((turn): turn is Record<string, unknown> => !!turn && typeof turn === 'object' && !Array.isArray(turn)) + .map((turn, index) => normalizeCodexTurn(turn, index)) + + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor: input.rawPage.nextCursor ?? null, + backwardsCursor: input.rawPage.backwardsCursor ?? null, + turns, + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) +} + +export function normalizeCodexTurnBody(input: { + threadId: string + revision: number + rawTurn: Record<string, unknown> +}) { + return FreshAgentTurnBodySchema.parse({ + ...normalizeCodexTurn(input.rawTurn), + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + }) +} + +export function normalizeCodexThreadSnapshot(input: { + threadId: string + revision: number + status: string + transcript: { turns: FreshAgentTurn[] } + rawSnapshot: CodexRawSnapshot +}) { + const extensions = input.rawSnapshot.extension?.codex ?? {} + const isRunning = input.status === 'running' || input.status === 'compacting' + return FreshAgentSnapshotSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex' as const, + threadId: input.threadId, + revision: input.revision, + status: input.status, + summary: input.rawSnapshot.summary ?? input.rawSnapshot.thread?.preview ?? input.transcript.turns[0]?.summary ?? '', + capabilities: { + send: !isRunning, + interrupt: isRunning, + approvals: false, + questions: false, + fork: !isRunning, + worktrees: (input.rawSnapshot.worktrees?.length ?? 0) > 0, + diffs: (input.rawSnapshot.diffs?.length ?? 0) > 0, + childThreads: (input.rawSnapshot.childThreads?.length ?? 0) > 0, + }, + tokenUsage: input.rawSnapshot.tokenUsage ?? { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + }, + worktrees: input.rawSnapshot.worktrees ?? [], + diffs: input.rawSnapshot.diffs ?? [], + childThreads: input.rawSnapshot.childThreads ?? [], + turns: input.transcript.turns, + extensions: { + codex: extensions, + }, + }) +} diff --git a/server/fresh-agent/provider-registry.ts b/server/fresh-agent/provider-registry.ts new file mode 100644 index 000000000..0759067ba --- /dev/null +++ b/server/fresh-agent/provider-registry.ts @@ -0,0 +1,39 @@ +import type { FreshAgentSessionType, FreshAgentRuntimeProvider } from '../../shared/fresh-agent.js' +import type { FreshAgentRuntimeAdapter } from './runtime-adapter.js' + +export type FreshAgentProviderRegistration = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter +} + +export class FreshAgentProviderRegistry { + private readonly registrationsBySessionType = new Map<FreshAgentSessionType, FreshAgentProviderRegistration>() + private readonly registrationsByRuntimeProvider = new Map<FreshAgentRuntimeProvider, FreshAgentProviderRegistration>() + + constructor(registrations: FreshAgentProviderRegistration[]) { + for (const registration of registrations) { + this.registrationsBySessionType.set(registration.sessionType, registration) + const runtimeRegistration = this.registrationsByRuntimeProvider.get(registration.runtimeProvider) + if (!runtimeRegistration) { + this.registrationsByRuntimeProvider.set(registration.runtimeProvider, registration) + } else if (runtimeRegistration.adapter !== registration.adapter) { + throw new Error( + `Fresh-agent runtime provider ${registration.runtimeProvider} has multiple adapters; register shared session types with the same adapter instance.`, + ) + } + } + } + + resolveBySessionType(sessionType: FreshAgentSessionType): FreshAgentProviderRegistration | undefined { + return this.registrationsBySessionType.get(sessionType) + } + + resolveByRuntimeProvider(runtimeProvider: FreshAgentRuntimeProvider): FreshAgentProviderRegistration | undefined { + return this.registrationsByRuntimeProvider.get(runtimeProvider) + } +} + +export function createFreshAgentProviderRegistry(registrations: FreshAgentProviderRegistration[]) { + return new FreshAgentProviderRegistry(registrations) +} diff --git a/server/fresh-agent/router.ts b/server/fresh-agent/router.ts new file mode 100644 index 000000000..b7ae2fee4 --- /dev/null +++ b/server/fresh-agent/router.ts @@ -0,0 +1,189 @@ +import { Router } from 'express' +import { z } from 'zod' + +import { + AgentTimelinePageQuerySchema, + AgentTimelineTurnBodyQuerySchema, + ReadModelPrioritySchema, +} from '../../shared/read-models.js' +import { + FreshAgentRuntimeManager, + FreshAgentRuntimeUnavailableError, + FreshAgentStaleThreadRevisionError, + FreshAgentUnsupportedCapabilityError, + FreshAgentLostSessionError, + FreshAgentSessionLocatorMismatchError, + FreshAgentContractValidationError, +} from './runtime-manager.js' +import { createRequestAbortSignal } from '../read-models/request-abort.js' +import { setResponsePerfContext } from '../request-logger.js' +import { + defaultReadModelScheduler, + isReadModelAbortError, + type ReadModelWorkScheduler, +} from '../read-models/work-scheduler.js' + +const ThreadParamsSchema = z.object({ + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + threadId: z.string().min(1), +}) + +const TurnParamsSchema = ThreadParamsSchema.extend({ + turnId: z.string().min(1), +}) + +export function createFreshAgentRouter(deps: { + runtimeManager: FreshAgentRuntimeManager + readModelScheduler?: ReadModelWorkScheduler +}) { + const router = Router() + const readModelScheduler = deps.readModelScheduler ?? defaultReadModelScheduler + + function sendFreshAgentError(res: any, error: unknown) { + if (error instanceof FreshAgentStaleThreadRevisionError) { + return res.status(409).json({ + error: 'Stale thread revision', + code: error.code, + currentRevision: error.currentRevision, + }) + } + if (error instanceof FreshAgentRuntimeUnavailableError) { + return res.status(503).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentUnsupportedCapabilityError) { + return res.status(409).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentLostSessionError) { + return res.status(404).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentSessionLocatorMismatchError) { + return res.status(409).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentContractValidationError) { + return res.status(502).json({ + error: error.message, + code: error.code, + surface: error.surface, + details: error.details, + }) + } + const message = error instanceof Error ? error.message : 'Fresh-agent request failed' + return res.status(500).json({ error: message }) + } + + router.get('/fresh-agent/threads/:sessionType/:provider/:threadId', async (req, res) => { + const params = ThreadParamsSchema.safeParse(req.params) + const query = z.object({ + priority: ReadModelPrioritySchema.optional(), + revision: z.coerce.number().int().nonnegative().optional(), + }).safeParse({ + priority: typeof req.query.priority === 'string' ? req.query.priority : undefined, + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + }) + + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + const signal = createRequestAbortSignal(req, res) + try { + const snapshot = await readModelScheduler.schedule({ + lane: query.data.priority ?? 'visible', + signal, + run: async () => deps.runtimeManager.getSnapshot({ + sessionType: params.data.sessionType, + provider: params.data.provider, + threadId: params.data.threadId, + revision: query.data.revision, + }), + }) + setResponsePerfContext(res, { + readModelLane: query.data.priority ?? 'visible', + responsePayloadBytes: Buffer.byteLength(JSON.stringify(snapshot), 'utf8'), + }) + res.json(snapshot) + } catch (error) { + if (signal.aborted || isReadModelAbortError(error)) return + return sendFreshAgentError(res, error) + } + }) + + router.get('/fresh-agent/threads/:sessionType/:provider/:threadId/turns', async (req, res) => { + const params = ThreadParamsSchema.safeParse(req.params) + const query = AgentTimelinePageQuerySchema.safeParse({ + cursor: typeof req.query.cursor === 'string' ? req.query.cursor : undefined, + priority: typeof req.query.priority === 'string' ? req.query.priority : undefined, + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + limit: typeof req.query.limit === 'string' ? Number(req.query.limit) : undefined, + includeBodies: typeof req.query.includeBodies === 'string' ? req.query.includeBodies : undefined, + }) + + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + const signal = createRequestAbortSignal(req, res) + try { + const page = await readModelScheduler.schedule({ + lane: query.data.priority ?? 'visible', + signal, + run: async () => deps.runtimeManager.getTurnPage({ + sessionType: params.data.sessionType, + provider: params.data.provider, + threadId: params.data.threadId, + cursor: query.data.cursor, + priority: query.data.priority, + revision: query.data.revision, + limit: query.data.limit, + includeBodies: query.data.includeBodies, + }), + }) + setResponsePerfContext(res, { + readModelLane: query.data.priority ?? 'visible', + responsePayloadBytes: Buffer.byteLength(JSON.stringify(page), 'utf8'), + }) + res.json(page) + } catch (error) { + if (signal.aborted || isReadModelAbortError(error)) return + return sendFreshAgentError(res, error) + } + }) + + router.get('/fresh-agent/threads/:sessionType/:provider/:threadId/turns/:turnId', async (req, res) => { + const params = TurnParamsSchema.safeParse(req.params) + const query = AgentTimelineTurnBodyQuerySchema.safeParse({ + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + }) + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + try { + const turn = await deps.runtimeManager.getTurnBody({ + sessionType: params.data.sessionType, + provider: params.data.provider, + threadId: params.data.threadId, + turnId: params.data.turnId, + revision: query.data.revision, + }) + if (!turn) { + return res.status(404).json({ error: 'Turn not found' }) + } + res.json(turn) + } catch (error) { + return sendFreshAgentError(res, error) + } + }) + + return router +} diff --git a/server/fresh-agent/runtime-adapter.ts b/server/fresh-agent/runtime-adapter.ts new file mode 100644 index 000000000..ad81a4675 --- /dev/null +++ b/server/fresh-agent/runtime-adapter.ts @@ -0,0 +1,57 @@ +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../../shared/fresh-agent.js' +import type { FreshAgentRequestId } from '../../shared/fresh-agent-contract.js' + +export type FreshAgentCreateRequest = { + requestId: string + sessionType: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider + cwd?: string + resumeSessionId?: string + sessionRef?: { provider: string; sessionId: string } + model?: string + modelSelection?: { kind: string; modelId: string } + permissionMode?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + effort?: string + plugins?: string[] +} + +export type FreshAgentCreateResult = { + sessionId: string + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + sessionRef?: { provider: string; sessionId: string } +} + +export type FreshAgentThreadLocator = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + threadId: string +} + +export type FreshAgentSessionLocator = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId: string +} + +export type FreshAgentInputImage = + | { kind: 'url'; url: string; mediaType?: string } + | { kind: 'local'; path: string; mediaType?: string } + | { kind: 'data'; mediaType: string; data: string } + +export interface FreshAgentRuntimeAdapter { + readonly runtimeProvider: FreshAgentRuntimeProvider + create(input: FreshAgentCreateRequest): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> + resume?(input: FreshAgentCreateRequest): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> + subscribe?(sessionId: string, listener: (message: unknown) => void): Promise<() => void> | (() => void) + send?(sessionId: string, input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }): Promise<void> | void + interrupt?(sessionId: string): Promise<void> | void + kill?(sessionId: string): Promise<boolean> | boolean + fork?(sessionId: string, input?: Record<string, unknown>): Promise<unknown> | unknown + answerQuestion?(sessionId: string, requestId: FreshAgentRequestId, answers: Record<string, string>): Promise<void> | void + resolveApproval?(sessionId: string, requestId: FreshAgentRequestId, decision: Record<string, unknown>): Promise<void> | void + getSnapshot?(thread: FreshAgentThreadLocator, revision?: number): Promise<unknown> + getTurnPage?(thread: FreshAgentThreadLocator, query: Record<string, unknown>): Promise<unknown> + getTurnBody?(thread: FreshAgentThreadLocator & { turnId: string }, revision: number): Promise<unknown> +} diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts new file mode 100644 index 000000000..3036dd838 --- /dev/null +++ b/server/fresh-agent/runtime-manager.ts @@ -0,0 +1,295 @@ +import { + makeFreshAgentSessionKey, + type FreshAgentRuntimeProvider, + type FreshAgentSessionType, +} from '../../shared/fresh-agent.js' +import { + FreshAgentSnapshotSchema, + FreshAgentTurnBodySchema, + FreshAgentTurnPageSchema, + type FreshAgentRequestId, +} from '../../shared/fresh-agent-contract.js' +import type { FreshAgentProviderRegistry } from './provider-registry.js' +import type { + FreshAgentCreateRequest, + FreshAgentCreateResult, + FreshAgentInputImage, + FreshAgentRuntimeAdapter, + FreshAgentSessionLocator, +} from './runtime-adapter.js' + +export class FreshAgentRuntimeUnavailableError extends Error { + readonly code = 'FRESH_AGENT_RUNTIME_UNAVAILABLE' as const +} + +export class FreshAgentStaleThreadRevisionError extends Error { + readonly code = 'STALE_THREAD_REVISION' as const + + constructor(readonly currentRevision: number) { + super('Fresh-agent thread revision is stale') + } +} + +export class FreshAgentUnsupportedCapabilityError extends Error { + readonly code = 'FRESH_AGENT_UNSUPPORTED_CAPABILITY' as const +} + +export class FreshAgentLostSessionError extends Error { + readonly code = 'FRESH_AGENT_LOST_SESSION' as const +} + +export class FreshAgentSessionLocatorMismatchError extends Error { + readonly code = 'FRESH_AGENT_SESSION_LOCATOR_MISMATCH' as const +} + +export class FreshAgentContractValidationError extends Error { + readonly code = 'FRESH_AGENT_CONTRACT_INVALID' as const + + constructor(readonly surface: 'snapshot' | 'turn-page' | 'turn-body', readonly details: unknown) { + super(`Fresh-agent ${surface} did not match the shared contract`) + } +} + +type FreshAgentRuntimeManagerOptions = { + registry: FreshAgentProviderRegistry +} + +type SessionRecord = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter +} + +export class FreshAgentRuntimeManager { + private readonly sessions = new Map<string, SessionRecord>() + + constructor(private readonly options: FreshAgentRuntimeManagerOptions) {} + + async create(input: FreshAgentCreateRequest): Promise<FreshAgentCreateResult> { + const registration = this.requireRegistration(input.sessionType, input.provider) + + const created = input.resumeSessionId && registration.adapter.resume + ? await registration.adapter.resume(input) + : await registration.adapter.create(input) + this.sessions.set(this.key({ + sessionType: input.sessionType, + provider: registration.runtimeProvider, + sessionId: created.sessionId, + }), { + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + }) + return { + sessionId: created.sessionId, + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + sessionRef: created.sessionRef, + } + } + + attach(input: FreshAgentSessionLocator): FreshAgentCreateResult { + const registration = this.requireRegistration(input.sessionType, input.provider) + + this.sessions.set(this.key(input), { + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + }) + + return { + sessionId: input.sessionId, + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + } + } + + async resume(input: FreshAgentCreateRequest): Promise<FreshAgentCreateResult> { + const registration = this.requireRegistration(input.sessionType, input.provider) + if (!registration.adapter.resume) { + throw new FreshAgentUnsupportedCapabilityError(`Resume is not supported for ${input.sessionType}`) + } + const resumed = await registration.adapter.resume(input) + this.sessions.set(this.key({ + sessionType: input.sessionType, + provider: registration.runtimeProvider, + sessionId: resumed.sessionId, + }), { + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + }) + return { + sessionId: resumed.sessionId, + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + sessionRef: resumed.sessionRef, + } + } + + async subscribe(locator: FreshAgentSessionLocator, listener: (message: unknown) => void) { + const record = this.requireSession(locator) + if (!record.adapter.subscribe) { + throw new FreshAgentUnsupportedCapabilityError(`Subscribe is not supported for ${record.sessionType}`) + } + return await record.adapter.subscribe(locator.sessionId, listener) + } + + async send(locator: FreshAgentSessionLocator, input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }) { + const record = this.requireSession(locator) + if (!record.adapter.send) { + throw new FreshAgentUnsupportedCapabilityError(`Send is not supported for ${record.sessionType}`) + } + await record.adapter.send(locator.sessionId, input) + } + + async interrupt(locator: FreshAgentSessionLocator) { + const record = this.requireSession(locator) + if (!record.adapter.interrupt) { + throw new FreshAgentUnsupportedCapabilityError(`Interrupt is not supported for ${record.sessionType}`) + } + await record.adapter.interrupt(locator.sessionId) + } + + async kill(locator: FreshAgentSessionLocator): Promise<boolean> { + const record = this.requireSession(locator) + try { + if (record.adapter.kill) { + return await record.adapter.kill(locator.sessionId) + } + return true + } finally { + this.sessions.delete(this.key(locator)) + } + } + + async fork(locator: FreshAgentSessionLocator, input?: Record<string, unknown>) { + const record = this.requireSession(locator) + if (!record.adapter.fork) { + throw new FreshAgentUnsupportedCapabilityError(`Fork is not supported for ${record.sessionType}`) + } + return await record.adapter.fork(locator.sessionId, input) + } + + async answerQuestion(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, answers: Record<string, string>) { + const record = this.requireSession(locator) + if (!record.adapter.answerQuestion) { + throw new FreshAgentUnsupportedCapabilityError(`Questions are not supported for ${record.sessionType}`) + } + await record.adapter.answerQuestion(locator.sessionId, requestId, answers) + } + + async resolveApproval(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, decision: Record<string, unknown>) { + const record = this.requireSession(locator) + if (!record.adapter.resolveApproval) { + throw new FreshAgentUnsupportedCapabilityError(`Approvals are not supported for ${record.sessionType}`) + } + await record.adapter.resolveApproval(locator.sessionId, requestId, decision) + } + + async getSnapshot(input: { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + threadId: string + revision?: number + }) { + const registration = this.requireRegistration(input.sessionType, input.provider) + if (!registration?.adapter.getSnapshot) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent snapshot adapter registered for ${input.sessionType}`) + } + const snapshot = await registration.adapter.getSnapshot({ + sessionType: input.sessionType, + provider: input.provider, + threadId: input.threadId, + }, input.revision) + const parsed = FreshAgentSnapshotSchema.safeParse(snapshot) + if (!parsed.success) { + throw new FreshAgentContractValidationError('snapshot', parsed.error.issues) + } + return parsed.data + } + + async getTurnPage(input: { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + threadId: string + cursor?: string + priority?: string + revision: number + limit?: number + includeBodies?: boolean + }) { + const registration = this.requireRegistration(input.sessionType, input.provider) + if (!registration?.adapter.getTurnPage) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-page adapter registered for ${input.sessionType}`) + } + const page = await registration.adapter.getTurnPage( + { sessionType: input.sessionType, provider: input.provider, threadId: input.threadId }, + input, + ) + const parsed = FreshAgentTurnPageSchema.safeParse(page) + if (!parsed.success) { + throw new FreshAgentContractValidationError('turn-page', parsed.error.issues) + } + return parsed.data + } + + async getTurnBody(input: { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + threadId: string + turnId: string + revision: number + }) { + const registration = this.requireRegistration(input.sessionType, input.provider) + if (!registration?.adapter.getTurnBody) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-body adapter registered for ${input.sessionType}`) + } + const body = await registration.adapter.getTurnBody( + { + sessionType: input.sessionType, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + }, + input.revision, + ) + const parsed = FreshAgentTurnBodySchema.safeParse(body) + if (!parsed.success) { + throw new FreshAgentContractValidationError('turn-body', parsed.error.issues) + } + return parsed.data + } + + private requireRegistration(sessionType: FreshAgentSessionType, provider?: FreshAgentRuntimeProvider) { + const registration = this.options.registry.resolveBySessionType(sessionType) + if (!registration) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent adapter registered for ${sessionType}`) + } + if (provider && registration.runtimeProvider !== provider) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session type ${sessionType} uses ${registration.runtimeProvider}, not ${provider}`, + ) + } + return registration + } + + private key(locator: FreshAgentSessionLocator): string { + return makeFreshAgentSessionKey(locator) + } + + private requireSession(locator: FreshAgentSessionLocator): SessionRecord { + const record = this.sessions.get(this.key(locator)) + if (!record) { + throw new FreshAgentLostSessionError( + `Fresh-agent session ${locator.sessionType}/${locator.provider}/${locator.sessionId} is not tracked`, + ) + } + if (record.sessionType !== locator.sessionType || record.runtimeProvider !== locator.provider) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is tracked as ${record.sessionType}/${record.runtimeProvider}, not ${locator.sessionType}/${locator.provider}`, + ) + } + return record + } +} diff --git a/server/index.ts b/server/index.ts index d443fae17..dc045016e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -73,6 +73,11 @@ import { createAgentHistorySource } from './agent-timeline/history-source.js' import { createTerminalViewService } from './terminal-view/service.js' import { resolveStartupBanner } from './startup-banner.js' import { shouldPromoteSessionTitle } from './session-title-sync.js' +import { createFreshAgentProviderRegistry } from './fresh-agent/provider-registry.js' +import { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js' +import { createFreshAgentRouter } from './fresh-agent/router.js' +import { createClaudeFreshAgentAdapter } from './fresh-agent/adapters/claude/adapter.js' +import { createCodexFreshAgentAdapter } from './fresh-agent/adapters/codex/adapter.js' import { CodexAppServerRuntime, runCodexStartupReaper, @@ -188,7 +193,7 @@ async function main() { const sessionMetadataStore = new SessionMetadataStore(freshellConfigDir) const codingCliIndexer = new CodingCliSessionIndexer(codingCliProviders, {}, sessionMetadataStore) const codingCliSessionManager = new CodingCliSessionManager(codingCliProviders) - const tabsRegistryStore = createTabsRegistryStore() + const tabsRegistryStore = await createTabsRegistryStore() const settings = migrateSettingsSortMode(await configStore.getSettings()) AI_CONFIG.applySettingsKey(settings.ai?.geminiApiKey) @@ -298,6 +303,33 @@ async function main() { sdkBridge = new SdkBridge(agentHistorySource) const server = http.createServer(app) + const codexFreshAgentRuntime = new CodexAppServerRuntime({ serverInstanceId }) + const claudeFreshAgentAdapter = createClaudeFreshAgentAdapter({ + sdkBridge, + agentHistorySource, + }) + const codexFreshAgentAdapter = createCodexFreshAgentAdapter({ + runtime: codexFreshAgentRuntime, + }) + const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ + registry: createFreshAgentProviderRegistry([ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + adapter: claudeFreshAgentAdapter, + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + adapter: claudeFreshAgentAdapter, + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexFreshAgentAdapter, + }, + ]), + }) const codexLaunchPlanner = new CodexLaunchPlanner(() => new CodexAppServerRuntime({ serverInstanceId })) const wsHandler = new WsHandler( server, @@ -306,6 +338,7 @@ async function main() { codingCliManager: codingCliSessionManager, codexLaunchPlanner, sdkBridge, + freshAgentRuntimeManager, sessionRepairService, handshakeSnapshotProvider: async () => { const currentSettings = migrateSettingsSortMode(await configStore.getSettings()) @@ -354,6 +387,9 @@ async function main() { codexLaunchPlanner, assertTerminalCreateAccepted, })) + app.use('/api', createFreshAgentRouter({ + runtimeManager: freshAgentRuntimeManager, + })) // --- Extension lifecycle broadcasts --- extensionManager.on('server.starting', ({ name }: { name: string }) => { @@ -378,6 +414,12 @@ async function main() { opencodeActivity.tracker.on('changed', (payload) => { wsHandler.broadcastOpencodeActivityUpdated(payload) }) + opencodeActivity.tracker.on('turn.complete', (payload) => { + wsHandler.broadcastTerminalTurnComplete({ + provider: 'opencode', + ...payload, + }) + }) opencodeActivity.controller.on('associated', ({ terminalId, sessionId }) => { try { broadcastTerminalSessionAssociation({ @@ -439,6 +481,29 @@ async function main() { } }) + registry.on('terminal.session.bound', (payload) => { + const event = payload as { + terminalId?: string + provider?: CodingCliProviderName + sessionId?: string + } + if (event.provider !== 'codex') return + if (!event.terminalId || !event.sessionId) return + try { + broadcastTerminalSessionAssociation({ + wsHandler, + terminalMetadata, + broadcastTerminalMetaUpserts, + provider: 'codex', + terminalId: event.terminalId, + sessionId: event.sessionId, + source: 'codex_durability', + }) + } catch (err) { + log.warn({ err, terminalId: event.terminalId, sessionId: event.sessionId }, 'Failed to broadcast Codex session association') + } + }) + const applyDebugLogging = (enabled: boolean, source: string) => { const nextEnabled = !!enabled setLogLevel(resolveRuntimeLogLevel(nextEnabled)) @@ -814,6 +879,7 @@ async function main() { await joinCodexShutdownOwners({ registry, codexLaunchPlanner, + codexFreshAgentRuntime, terminalShutdownTimeoutMs: 5000, }) } finally { diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index 085dfad00..1bd8486b1 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { createApiClient, resolveConfig, type ApiClient } from './http-client.js' import { translateKeys } from '../cli/keys.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js' // Lazy-initialized client -- created on first use so env vars are read at call time. let _client: ApiClient | undefined @@ -46,7 +47,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Mental model - Tabs contain pane trees (splits). Panes contain content. -- Pane kinds: terminal, editor, browser, agent-chat (Claude/Codex/etc.), picker (transient). +- Pane kinds: terminal, editor, browser, fresh-agent (Claude/Codex/etc.), agent-chat (legacy), picker (transient). - **Picker panes are ephemeral.** A freshly-created tab without mode/browser/editor starts as a picker pane while the user chooses what to launch. Once they select, the picker is replaced by the real pane with a **new pane ID**. Never target a picker pane for splits or mutations -- use mode/browser/editor params on new-tab/split-pane to skip the picker entirely. - Typical workflow: new-tab -> send-keys -> wait-for -> capture-pane/screenshot. @@ -70,7 +71,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Key gotchas - **Tab and pane IDs are ephemeral.** IDs from open-browser, new-tab, and split-pane are valid only within the current session. If the Freshell server restarts or the agent conversation resumes after a disconnect, previously returned IDs may no longer exist. Always call open-browser or list-tabs fresh rather than reusing stale IDs. -- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding. +- **Always screenshot with screenshot({ scope: "tab", target: tabId }) after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding. - send-keys: use literal mode (literal: true + keys as a string) for natural-language prompts or multi-word text. Do NOT append "ENTER" as literal text -- send the command with literal:true, then send ["ENTER"] as a separate call in token mode. - wait-for with stable (seconds of no output) is more reliable than pattern matching across different CLI providers. - Editor panes show "Loading..." until the tab is visited in the browser. When screenshotting multiple tabs, visit each tab first (select-tab), then loop back for screenshots. @@ -246,7 +247,7 @@ async function handleDisplay(format: string, target?: string): Promise<string> { // --------------------------------------------------------------------------- const ACTION_PARAMS: Record<string, { required: string[]; optional: string[] }> = { - 'new-tab': { required: [], optional: ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'prompt'] }, + 'new-tab': { required: [], optional: ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'sessionRef', 'prompt'] }, 'list-tabs': { required: [], optional: [] }, 'select-tab': { required: ['target'], optional: [] }, 'kill-tab': { required: ['target'], optional: [] }, @@ -254,14 +255,14 @@ const ACTION_PARAMS: Record<string, { required: string[]; optional: string[] }> 'has-tab': { required: ['target'], optional: [] }, 'next-tab': { required: [], optional: [] }, 'prev-tab': { required: [], optional: [] }, - 'split-pane': { required: [], optional: ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor'] }, + 'split-pane': { required: [], optional: ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'sessionRef'] }, 'list-panes': { required: [], optional: ['target'] }, 'select-pane': { required: ['target'], optional: [] }, 'rename-pane': { required: ['name'], optional: ['target'] }, 'kill-pane': { required: ['target'], optional: [] }, 'resize-pane': { required: ['target'], optional: ['x', 'y', 'sizes'] }, 'swap-pane': { required: ['target', 'with'], optional: [] }, - 'respawn-pane': { required: ['target'], optional: ['mode', 'shell', 'cwd'] }, + 'respawn-pane': { required: ['target'], optional: ['mode', 'shell', 'cwd', 'resume', 'sessionRef'] }, 'send-keys': { required: [], optional: ['target', 'keys', 'literal'] }, 'capture-pane': { required: [], optional: ['target', 'S', 'J', 'e'] }, 'wait-for': { required: [], optional: ['target', 'pattern', 'stable', 'exit', 'prompt', 'timeout'] }, @@ -280,6 +281,8 @@ const ACTION_PARAMS: Record<string, { required: string[]; optional: string[] }> 'help': { required: [], optional: [] }, } +const RAW_CODEX_RESUME_HINT = 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.' + const COMMON_CONFUSIONS: Record<string, Record<string, string>> = { 'new-tab': { url: "Unknown parameter 'url' for action 'new-tab'. Did you mean to use 'open-browser' to open a URL? Or pass the URL as 'browser' to create a browser pane in a new tab.", @@ -309,6 +312,29 @@ function validateParams(action: string, params: Record<string, unknown> | undefi } } +function isCodexSessionRef(value: unknown): boolean { + return !!value + && typeof value === 'object' + && !Array.isArray(value) + && (value as { provider?: unknown }).provider === 'codex' + && typeof (value as { sessionId?: unknown }).sessionId === 'string' + && (value as { sessionId: string }).sessionId.length > 0 +} + +function rejectRawCodexResume( + mode: unknown, + resume: unknown, + sessionRef: unknown, +): { error: string; hint: string } | undefined { + if (mode === 'codex' && typeof resume === 'string' && resume.length > 0 && !isCodexSessionRef(sessionRef)) { + return { + error: INVALID_RAW_CODEX_RESUME_MESSAGE, + hint: RAW_CODEX_RESUME_HINT, + } + } + return undefined +} + // --------------------------------------------------------------------------- // Action router // --------------------------------------------------------------------------- @@ -335,7 +361,7 @@ Rules: ## Command reference Tab commands: - new-tab Create a tab with a terminal pane (default). Params: name?, mode?, shell?, cwd?, browser?, editor?, resume?, prompt? + new-tab Create a tab with a terminal pane (default). Params: name?, mode?, shell?, cwd?, browser?, editor?, resume?, sessionRef?, prompt? mode values: shell (default), claude, codex, kimi, opencode, or any supported CLI. prompt: text to send to the terminal after creation (via send-keys with literal mode). To open a URL in a browser pane, use 'open-browser' instead. @@ -349,7 +375,7 @@ Tab commands: prev-tab Switch to the previous tab. Pane commands: - split-pane Split a pane. Params: target?, direction (horizontal|vertical, default vertical), mode?, shell?, cwd?, browser?, editor? + split-pane Split a pane. Params: target?, direction (horizontal|vertical, default vertical), mode?, shell?, cwd?, browser?, editor?, resume?, sessionRef? Omit target to split your own pane (the pane where this MCP server was spawned). Returns { paneId, tabId }. list-panes List panes. Params: target? (tab ID or title to filter by). Returns { panes: [...] }. select-pane Activate a pane. Params: target (pane ID or index) @@ -358,7 +384,7 @@ Pane commands: Omit target to rename the caller pane (or the tab's active pane as fallback). resize-pane Resize a pane. Params: target, x? (1-99), y? (1-99) swap-pane Swap two panes. Params: target, with (other pane ID) - respawn-pane Restart a pane's terminal. Params: target, mode?, shell?, cwd? + respawn-pane Restart a pane's terminal. Params: target, mode?, shell?, cwd?, resume?, sessionRef? Terminal I/O: send-keys Send input to a pane. Params: target, keys, literal? @@ -469,7 +495,7 @@ Meta: ## Screenshot guidance -- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it. +- **Always screenshot with screenshot({ scope: "tab", target: tabId }) after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it. - Tab and pane IDs from earlier in a session may become stale after reconnections or server restarts. If screenshot fails to find a tab/pane, call list-tabs or list-panes to get fresh IDs rather than reusing old ones. - Use a dedicated canary tab when validating screenshot behavior so live project panes are not contaminated. - Close temporary tabs/panes after verification unless user asked to keep them open. @@ -540,10 +566,12 @@ async function routeAction( switch (action) { // -- Tab actions -- case 'new-tab': { - const { name, mode, shell, cwd, browser, editor, resume, prompt, ...rest } = params || {} - const sessionRef = typeof mode === 'string' && typeof resume === 'string' + const { name, mode, shell, cwd, browser, editor, resume, sessionRef: explicitSessionRef, prompt, ...rest } = params || {} + const codexResumeError = rejectRawCodexResume(mode, resume, explicitSessionRef) + if (codexResumeError) return codexResumeError + const sessionRef = explicitSessionRef ?? (typeof mode === 'string' && mode !== 'codex' && typeof resume === 'string' ? { provider: mode, sessionId: resume } - : undefined + : undefined) const tabResult = await c.post('/api/tabs', { name, mode, @@ -559,7 +587,10 @@ async function routeAction( const data = unwrapData(tabResult) const paneId = data?.paneId if (paneId) { - await c.post(`/api/panes/${encodeURIComponent(paneId)}/send-keys`, { data: `${prompt}\r` }) + await c.post(`/api/panes/${encodeURIComponent(paneId)}/send-keys`, { + data: `${prompt}\r`, + ...(mode === 'codex' ? { waitForCodexIdentity: true } : {}), + }) } } return tabResult @@ -602,9 +633,14 @@ async function routeAction( const resolved = await resolvePaneTarget(rawTarget) if (!resolved.pane) return { error: resolved.message || 'No pane found', hint: "Run action 'list-panes' to see available panes." } const paneId = resolved.pane.id - const { direction, browser, editor, mode, shell, cwd, target: _t, ...rest } = params || {} + const { direction, browser, editor, mode, shell, cwd, target: _t, resume, sessionRef, ...rest } = params || {} + const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) + if (codexResumeError) return codexResumeError + const effectiveSessionRef = sessionRef ?? (typeof mode === 'string' && mode !== 'codex' && typeof resume === 'string' + ? { provider: mode, sessionId: resume } + : undefined) return c.post(`/api/panes/${encodeURIComponent(paneId)}/split`, { - direction, browser, editor, mode, shell, cwd, ...rest, + direction, browser, editor, mode, shell, cwd, ...(effectiveSessionRef ? { sessionRef: effectiveSessionRef } : {}), ...rest, }) } case 'list-panes': { @@ -646,8 +682,13 @@ async function routeAction( } case 'respawn-pane': { const target = requireParam(params, 'target') - const { mode, shell, cwd } = params || {} - return c.post(`/api/panes/${encodeURIComponent(target)}/respawn`, { mode, shell, cwd }) + const { mode, shell, cwd, resume, sessionRef } = params || {} + const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) + if (codexResumeError) return codexResumeError + const effectiveSessionRef = sessionRef ?? (typeof mode === 'string' && mode !== 'codex' && typeof resume === 'string' + ? { provider: mode, sessionId: resume } + : undefined) + return c.post(`/api/panes/${encodeURIComponent(target)}/respawn`, { mode, shell, cwd, sessionRef: effectiveSessionRef }) } // -- Terminal I/O -- diff --git a/server/sdk-bridge-types.ts b/server/sdk-bridge-types.ts index 0ba1b337b..b852125e1 100644 --- a/server/sdk-bridge-types.ts +++ b/server/sdk-bridge-types.ts @@ -74,6 +74,7 @@ export interface SdkSessionState { cwd?: string model?: string permissionMode?: string + plugins?: string[] tools?: Array<{ name: string }> status: SdkSessionStatus createdAt: number diff --git a/server/sdk-bridge.ts b/server/sdk-bridge.ts index 9a8b5ca3a..f5d9f41d9 100644 --- a/server/sdk-bridge.ts +++ b/server/sdk-bridge.ts @@ -114,6 +114,7 @@ export class SdkBridge extends EventEmitter { private cloneSessionState(state: SdkSessionState): SdkSessionState { return { ...state, + plugins: state.plugins ? [...state.plugins] : undefined, tools: state.tools ? state.tools.map((tool) => ({ ...tool })) : undefined, messages: state.messages.map((message) => ({ ...message, @@ -167,6 +168,7 @@ export class SdkBridge extends EventEmitter { cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, + plugins: options.plugins ? sanitizeAgentChatPluginPaths(options.plugins) : undefined, status: 'starting', createdAt: Date.now(), messages: [], diff --git a/server/session-association-broadcast.ts b/server/session-association-broadcast.ts index 71cec2d6d..a1e054c84 100644 --- a/server/session-association-broadcast.ts +++ b/server/session-association-broadcast.ts @@ -1,13 +1,175 @@ import { recordSessionLifecycleEvent } from './session-observability.js' import type { CodingCliProviderName } from './coding-cli/types.js' -import type { TerminalMetadataService } from './terminal-metadata-service.js' +import type { + TerminalMeta, + TerminalMetadataService, + TerminalSeedRecord, +} from './terminal-metadata-service.js' -type AssociationBroadcastSource = 'indexer_update' | 'claude_new_session' | 'opencode_controller' +type AssociationBroadcastSource = 'indexer_update' | 'claude_new_session' | 'opencode_controller' | 'codex_durability' + +export type AssociationPublicationStatus = + | 'published' + | 'deduped' + | 'pendingMetadata' + | 'rebound' + | 'seeded' + +type AssociationPublishRequest = { + provider: CodingCliProviderName + terminalId: string + sessionId: string + source: AssociationBroadcastSource +} + +type AssociationPublisherDeps = { + wsHandler: { broadcast: (message: unknown) => void } + terminalMetadata: Pick< + TerminalMetadataService, + 'associateSession' | 'clearSessionAssociation' | 'get' | 'list' | 'seedFromTerminal' + > + broadcastTerminalMetaUpserts: (upserts: TerminalMeta[]) => void +} + +function sessionKey(provider: CodingCliProviderName, sessionId: string): string { + return `${provider}\0${sessionId}` +} + +function pairKey(provider: CodingCliProviderName, sessionId: string, terminalId: string): string { + return `${sessionKey(provider, sessionId)}\0${terminalId}` +} + +export function createTerminalSessionAssociationPublisher(deps: AssociationPublisherDeps) { + const publishedPairs = new Set<string>() + const activeTerminalBySession = new Map<string, string>() + const pendingByTerminalId = new Map<string, AssociationPublishRequest>() + + const findActiveTerminalForSession = ( + provider: CodingCliProviderName, + sessionId: string, + ): string | undefined => { + const key = sessionKey(provider, sessionId) + const cached = activeTerminalBySession.get(key) + if (cached && deps.terminalMetadata.get(cached)) { + return cached + } + + const active = deps.terminalMetadata.list().find((meta) => ( + meta.provider === provider && meta.sessionId === sessionId + )) + return active?.terminalId + } + + const recordPublished = (request: AssociationPublishRequest) => { + recordSessionLifecycleEvent({ + kind: 'session_association_broadcast', + provider: request.provider, + terminalId: request.terminalId, + sessionId: request.sessionId, + source: request.source, + }) + } + + const publishWithMetadata = ( + request: AssociationPublishRequest, + seedUpsert?: TerminalMeta, + ): AssociationPublicationStatus => { + const key = pairKey(request.provider, request.sessionId, request.terminalId) + const existingTerminalId = findActiveTerminalForSession(request.provider, request.sessionId) + if (publishedPairs.has(key) && existingTerminalId === request.terminalId) { + return 'deduped' + } + + const rebound = Boolean(existingTerminalId && existingTerminalId !== request.terminalId) + const metaUpserts: TerminalMeta[] = [] + if (rebound && existingTerminalId) { + const stale = deps.terminalMetadata.clearSessionAssociation( + existingTerminalId, + request.provider, + request.sessionId, + ) + if (stale) metaUpserts.push(stale) + } + + deps.wsHandler.broadcast({ + type: 'terminal.session.associated' as const, + terminalId: request.terminalId, + sessionRef: { + provider: request.provider, + sessionId: request.sessionId, + }, + }) + + const associated = deps.terminalMetadata.associateSession( + request.terminalId, + request.provider, + request.sessionId, + ) + if (associated) { + metaUpserts.push(associated) + } else if (seedUpsert) { + metaUpserts.push(seedUpsert) + } + + if (metaUpserts.length > 0) { + deps.broadcastTerminalMetaUpserts(metaUpserts) + } + + pendingByTerminalId.delete(request.terminalId) + publishedPairs.add(key) + activeTerminalBySession.set(sessionKey(request.provider, request.sessionId), request.terminalId) + recordPublished(request) + return rebound ? 'rebound' : 'published' + } + + return { + publish(request: AssociationPublishRequest): AssociationPublicationStatus { + const key = pairKey(request.provider, request.sessionId, request.terminalId) + if ( + publishedPairs.has(key) + && findActiveTerminalForSession(request.provider, request.sessionId) === request.terminalId + ) { + return 'deduped' + } + + if (!deps.terminalMetadata.get(request.terminalId)) { + const pending = pendingByTerminalId.get(request.terminalId) + if ( + pending + && pending.provider === request.provider + && pending.sessionId === request.sessionId + ) { + return 'pendingMetadata' + } + pendingByTerminalId.set(request.terminalId, request) + return 'pendingMetadata' + } + + return publishWithMetadata(request) + }, + + async seedFromTerminal(record: TerminalSeedRecord): Promise<AssociationPublicationStatus> { + const pending = pendingByTerminalId.get(record.terminalId) + const seedUpsert = await deps.terminalMetadata.seedFromTerminal(record) + if (!pending) { + if (seedUpsert) { + deps.broadcastTerminalMetaUpserts([seedUpsert]) + } + return 'seeded' + } + return publishWithMetadata(pending, seedUpsert) + }, + + forgetTerminal(terminalId: string): void { + pendingByTerminalId.delete(terminalId) + }, + } +} export function broadcastTerminalSessionAssociation(opts: { wsHandler: { broadcast: (message: unknown) => void } terminalMetadata: Pick<TerminalMetadataService, 'associateSession'> - broadcastTerminalMetaUpserts: (upserts: ReturnType<TerminalMetadataService['list']>) => void + broadcastTerminalMetaUpserts: (upserts: TerminalMeta[]) => void provider: CodingCliProviderName terminalId: string sessionId: string diff --git a/server/session-association-coordinator.ts b/server/session-association-coordinator.ts index 0f2b92f2b..a31bcd4f7 100644 --- a/server/session-association-coordinator.ts +++ b/server/session-association-coordinator.ts @@ -78,7 +78,7 @@ export class SessionAssociationCoordinator { private associationCandidateReason( session: CodingCliSession, ): 'ok' | NonNullable<SessionAssociationResult['reason']> { - if (session.provider === 'codex') return 'provider_managed' + if (session.provider === 'codex' || session.provider === 'opencode') return 'provider_managed' if (!modeSupportsResume(session.provider)) return 'provider_not_supported' if (!session.cwd) return 'missing_cwd' if (session.isSubagent) return 'subagent' diff --git a/server/session-directory/service.ts b/server/session-directory/service.ts index ad004d1f7..029380b1f 100644 --- a/server/session-directory/service.ts +++ b/server/session-directory/service.ts @@ -94,13 +94,60 @@ function joinRunningState(item: SessionDirectoryItem, terminalMeta: TerminalMeta } } +function providerDisplayName(provider: string): string { + switch (provider) { + case 'claude': + return 'Claude CLI' + case 'codex': + return 'Codex CLI' + case 'opencode': + return 'OpenCode' + default: + return provider + } +} + +function buildLiveTerminalSessionItem(meta: TerminalMeta): SessionDirectoryItem | undefined { + if (!meta.provider) return undefined + + const sessionId = meta.sessionId || `terminal:${meta.terminalId}` + const projectPath = meta.checkoutRoot || meta.repoRoot || meta.cwd || `terminal:${meta.terminalId}` + + return { + provider: meta.provider, + sessionId, + projectPath, + checkoutPath: meta.checkoutRoot, + title: providerDisplayName(meta.provider), + lastActivityAt: meta.updatedAt, + createdAt: meta.updatedAt, + cwd: meta.cwd, + sessionType: meta.provider, + isRunning: true, + runningTerminalId: meta.terminalId, + liveTerminalOnly: !meta.sessionId, + } +} + function toItems(projects: ProjectGroup[], terminalMeta: TerminalMeta[]): SessionDirectoryItem[] { - return buildSessionDirectoryComparableSnapshot(projects).map((item) => ( + const items = buildSessionDirectoryComparableSnapshot(projects).map((item) => ( joinRunningState({ ...item, isRunning: false, }, terminalMeta) )) + const existingKeys = new Set(items.map(buildSessionKey)) + + for (const meta of terminalMeta) { + const item = buildLiveTerminalSessionItem(meta) + if (!item) continue + const key = buildSessionKey(item) + if (existingKeys.has(key)) continue + items.push(item) + existingKeys.add(key) + } + + return items } async function applyFileSearch( @@ -201,7 +248,7 @@ export async function querySessionDirectory(input: QuerySessionDirectoryInput): items = items.filter((item) => !item.isNonInteractive) } if (!input.query.includeEmpty) { - items = items.filter((item) => item.title != null && item.title !== '') + items = items.filter((item) => item.isRunning || !!item.title?.trim()) } if (cursor) { diff --git a/server/session-observability.ts b/server/session-observability.ts index de493ba7d..ca1a73d7a 100644 --- a/server/session-observability.ts +++ b/server/session-observability.ts @@ -29,6 +29,43 @@ export type SessionLifecycleEvent = reused: boolean hasSessionRef: boolean }) + | (OptionalUiContext & { + kind: 'restore_unavailable' + requestId: string + connectionId: string + mode: TerminalMode + reason: 'missing_canonical_session_id' + restoreRequested: boolean + hasSessionRef: boolean + }) + | (OptionalUiContext & { + kind: 'restore_unavailable_fresh_fallback' + requestId: string + connectionId: string + mode: TerminalMode + reason: 'fresh_after_restore_unavailable' + restoreRequested: false + treatedAsFresh: true + hasSessionRef: boolean + }) + | { + kind: 'codex_candidate_pending' + provider: 'codex' + terminalId: string + generation: number + tabId?: string + paneId?: string + cwd?: string + } + | { + kind: 'codex_candidate_captured' + provider: 'codex' + terminalId: string + candidateThreadId: string + rolloutPath: string + source: string + generation: number + } | { kind: 'codex_durable_session_observed' provider: 'codex' @@ -38,12 +75,20 @@ export type SessionLifecycleEvent = attemptId?: string source: 'sidecar' } + | { + kind: 'codex_durable_resume_started' + provider: 'codex' + terminalId: string + sessionId: string + generation: number + source: 'sidecar' + } | { kind: 'session_association_broadcast' provider: CodingCliProviderName terminalId: string sessionId: string - source: 'indexer_update' | 'claude_new_session' | 'opencode_controller' + source: 'indexer_update' | 'claude_new_session' | 'opencode_controller' | 'codex_durability' } | { kind: 'terminal_session_bound' @@ -99,6 +144,7 @@ function isIncidentEvent(kind: SessionLifecycleEvent['kind']): boolean { return kind === 'terminal_exit_without_durable_session' || kind === 'invalid_terminal_id_without_session_ref' || kind === 'client_restore_unavailable' + || kind === 'restore_unavailable' } function buildPayload(event: SessionLifecycleEvent): Record<string, unknown> { @@ -135,6 +181,53 @@ function buildPayload(event: SessionLifecycleEvent): Record<string, unknown> { reused: event.reused, hasSessionRef: event.hasSessionRef, } + case 'restore_unavailable': + return { + ...base, + requestId: event.requestId, + connectionId: event.connectionId, + tabId: event.tabId, + paneId: event.paneId, + cwd: event.cwd, + mode: event.mode, + reason: event.reason, + restoreRequested: event.restoreRequested, + hasSessionRef: event.hasSessionRef, + } + case 'restore_unavailable_fresh_fallback': + return { + ...base, + requestId: event.requestId, + connectionId: event.connectionId, + tabId: event.tabId, + paneId: event.paneId, + cwd: event.cwd, + mode: event.mode, + reason: event.reason, + restoreRequested: event.restoreRequested, + treatedAsFresh: event.treatedAsFresh, + hasSessionRef: event.hasSessionRef, + } + case 'codex_candidate_pending': + return { + ...base, + provider: event.provider, + terminalId: event.terminalId, + generation: event.generation, + tabId: event.tabId, + paneId: event.paneId, + cwd: event.cwd, + } + case 'codex_candidate_captured': + return { + ...base, + provider: event.provider, + terminalId: event.terminalId, + candidateThreadId: event.candidateThreadId, + rolloutPath: event.rolloutPath, + source: event.source, + generation: event.generation, + } case 'codex_durable_session_observed': return { ...base, @@ -145,6 +238,15 @@ function buildPayload(event: SessionLifecycleEvent): Record<string, unknown> { attemptId: event.attemptId, source: event.source, } + case 'codex_durable_resume_started': + return { + ...base, + provider: event.provider, + terminalId: event.terminalId, + sessionId: event.sessionId, + generation: event.generation, + source: event.source, + } case 'session_association_broadcast': return { ...base, diff --git a/server/shutdown-join.ts b/server/shutdown-join.ts index af0ae6b6e..924c6acf7 100644 --- a/server/shutdown-join.ts +++ b/server/shutdown-join.ts @@ -35,16 +35,21 @@ type CodexShutdownOwners = { codexLaunchPlanner: { shutdown(): Promise<void> } + codexFreshAgentRuntime?: { + shutdown(): Promise<void> + } terminalShutdownTimeoutMs: number } export async function joinCodexShutdownOwners({ registry, codexLaunchPlanner, + codexFreshAgentRuntime, terminalShutdownTimeoutMs, }: CodexShutdownOwners): Promise<void> { await waitForAllSettledOrThrow([ invokeShutdownTask(() => registry.shutdownGracefully(terminalShutdownTimeoutMs)), invokeShutdownTask(() => codexLaunchPlanner.shutdown()), + ...(codexFreshAgentRuntime ? [invokeShutdownTask(() => codexFreshAgentRuntime.shutdown())] : []), ], 'Codex shutdown owners failed.') } diff --git a/server/tabs-registry/store.ts b/server/tabs-registry/store.ts index cf2329117..69febc987 100644 --- a/server/tabs-registry/store.ts +++ b/server/tabs-registry/store.ts @@ -1,34 +1,309 @@ +import crypto from 'crypto' import fs from 'fs' import fsp from 'fs/promises' import path from 'path' +import { z } from 'zod' import { getFreshellConfigDir } from '../freshell-home.js' -import { TabsDeviceStore } from './device-store.js' import { TabRegistryRecordSchema, type RegistryTabRecord } from './types.js' const DAY_MS = 24 * 60 * 60 * 1000 -const DEFAULT_RANGE_DAYS = 1 +const MINUTE_MS = 60 * 1000 +const DEFAULT_CLOSED_RETENTION_DAYS = 30 +const DEFAULT_OPEN_SNAPSHOT_TTL_MINUTES = 30 +const DEFAULT_DEVICE_DISPLAY_TTL_DAYS = 7 -type TabsRegistryStoreOptions = { - now?: () => number - defaultRangeDays?: number +type ObjectRef = { + path: string + sha256: string + bytes: number +} + +export type RegistryDeviceEntry = { + deviceId: string + deviceLabel: string + lastSeenAt: number +} + +type ClientOpenSnapshot = { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + lastPushPayloadHash: string + openSnapshotPayloadHash: string + snapshotReceivedAt: number + records: RegistryTabRecord[] +} + +type ClientRevisionWatermark = { + deviceId: string + clientInstanceId: string + snapshotRevision: number + lastSeenAt: number +} + +type CompactTabsRegistryStateV1 = { + version: 1 + savedAt: number + openSnapshotTtlMinutes: number + deviceDisplayTtlDays: number + maxClosedRetentionDays: number + openSnapshotsByClient: Record<string, ClientOpenSnapshot> + clientRevisionsByClient: Record<string, ClientRevisionWatermark> + closedByTabKey: Record<string, RegistryTabRecord> + devicesById: Record<string, RegistryDeviceEntry> +} + +type TabsRegistryManifestV1 = { + version: 1 + manifestRevision: number + committedAt: number + openSnapshots: Record<string, ObjectRef> + clientRevisions: ObjectRef + closedTombstones: ObjectRef + devices: ObjectRef + settings: { + openSnapshotTtlMinutes: number + deviceDisplayTtlDays: number + maxClosedRetentionDays: number + } +} + +export type ReplaceClientSnapshotInput = { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + records: RegistryTabRecord[] +} + +export type RetireClientSnapshotInput = { + deviceId: string + clientInstanceId: string + snapshotRevision: number } export type TabsRegistryQueryInput = { deviceId: string - rangeDays?: number + clientInstanceId: string + closedTabRetentionDays: number } export type TabsRegistryQueryResult = { localOpen: RegistryTabRecord[] + sameDeviceOpen: RegistryTabRecord[] remoteOpen: RegistryTabRecord[] closed: RegistryTabRecord[] + devices: RegistryDeviceEntry[] +} + +export type TabsRegistryStoreOptions = { + now?: () => number + defaultClosedRetentionDays?: number + caps?: Partial<TabsRegistryCaps> +} + +type TabsRegistryCaps = { + maxRecordsPerPush: number + maxOpenRecordsPerClientSnapshot: number + maxClosedRecordsPerPush: number + maxPanesPerRecord: number + maxSerializedPushBytes: number + maxSerializedClientSnapshotObjectBytes: number + maxSerializedManifestBytes: number + maxSerializedClosedTombstoneObjectBytes: number + maxSerializedDeviceMetadataObjectBytes: number + maxCompactStateBytes: number + maxClientSnapshotRefs: number + maxClientRevisionWatermarks: number + maxDevices: number + maxClosedTombstones: number + maxLegacyLineBytes: number + maxLegacyUniqueTabKeys: number + maxMigrationRetainedBytes: number +} + +type FailurePoint = 'object-write' | 'object-rename' | 'manifest-write' | 'manifest-rename' + +const DEFAULT_CAPS: TabsRegistryCaps = { + maxRecordsPerPush: 500, + maxOpenRecordsPerClientSnapshot: 500, + maxClosedRecordsPerPush: 500, + maxPanesPerRecord: 20, + maxSerializedPushBytes: 1024 * 1024, + maxSerializedClientSnapshotObjectBytes: 512 * 1024, + maxSerializedManifestBytes: 256 * 1024, + maxSerializedClosedTombstoneObjectBytes: 2 * 1024 * 1024, + maxSerializedDeviceMetadataObjectBytes: 256 * 1024, + maxCompactStateBytes: 5 * 1024 * 1024, + maxClientSnapshotRefs: 200, + maxClientRevisionWatermarks: 200, + maxDevices: 200, + maxClosedTombstones: 2000, + maxLegacyLineBytes: 256 * 1024, + maxLegacyUniqueTabKeys: 10_000, + maxMigrationRetainedBytes: 5 * 1024 * 1024, +} + +const ObjectRefSchema = z.object({ + path: z.string().regex(/^objects\/[a-f0-9]{64}\.json$/), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + bytes: z.number().int().nonnegative(), +}).superRefine((value, ctx) => { + const pathDigest = /^objects\/([a-f0-9]{64})\.json$/.exec(value.path)?.[1] + if (pathDigest && pathDigest !== value.sha256) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Object reference path must be derived from the content hash', + path: ['path'], + }) + } +}) + +const ManifestSchema: z.ZodType<TabsRegistryManifestV1> = z.object({ + version: z.literal(1), + manifestRevision: z.number().int().nonnegative(), + committedAt: z.number().int().nonnegative(), + openSnapshots: z.record(z.string().min(1), ObjectRefSchema), + clientRevisions: ObjectRefSchema, + closedTombstones: ObjectRefSchema, + devices: ObjectRefSchema, + settings: z.object({ + openSnapshotTtlMinutes: z.literal(DEFAULT_OPEN_SNAPSHOT_TTL_MINUTES), + deviceDisplayTtlDays: z.literal(DEFAULT_DEVICE_DISPLAY_TTL_DAYS), + maxClosedRetentionDays: z.number().int().min(1).max(30), + }), +}) + +const ClientOpenSnapshotSchema: z.ZodType<ClientOpenSnapshot> = z.object({ + deviceId: z.string().min(1), + deviceLabel: z.string().min(1), + clientInstanceId: z.string().min(1), + snapshotRevision: z.number().int().nonnegative(), + lastPushPayloadHash: z.string().regex(/^[a-f0-9]{64}$/), + openSnapshotPayloadHash: z.string().regex(/^[a-f0-9]{64}$/), + snapshotReceivedAt: z.number().int().nonnegative(), + records: z.array(TabRegistryRecordSchema), +}).strict().superRefine((value, ctx) => { + for (const [index, record] of value.records.entries()) { + if (record.status !== 'open') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Client open snapshot records must contain open records only', + path: ['records', index, 'status'], + }) + } + if ( + record.deviceId !== value.deviceId + || record.deviceLabel !== value.deviceLabel + || record.clientInstanceId !== value.clientInstanceId + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Client open snapshot record identity must match the snapshot identity', + path: ['records', index], + }) + } + } +}) + +const DevicesSchema: z.ZodType<Record<string, RegistryDeviceEntry>> = z.record(z.string().min(1), z.object({ + deviceId: z.string().min(1), + deviceLabel: z.string().min(1), + lastSeenAt: z.number().int().nonnegative(), +})).superRefine((value, ctx) => { + for (const [key, device] of Object.entries(value)) { + if (key !== device.deviceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Tabs registry devices metadata key must match deviceId', + path: [key, 'deviceId'], + }) + } + } +}) + +const ClosedTombstonesSchema: z.ZodType<Record<string, RegistryTabRecord>> = z.record(z.string().min(1), TabRegistryRecordSchema) + .superRefine((value, ctx) => { + for (const [key, record] of Object.entries(value)) { + if (record.status !== 'closed') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Tabs registry closed tombstones must contain closed records only', + path: [key, 'status'], + }) + } + if (key !== record.tabKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Tabs registry closed tombstone key must match record tabKey', + path: [key, 'tabKey'], + }) + } + } + }) + +const ClientRevisionsSchema: z.ZodType<Record<string, ClientRevisionWatermark>> = z.record(z.string().min(1), z.object({ + deviceId: z.string().min(1), + clientInstanceId: z.string().min(1), + snapshotRevision: z.number().int().nonnegative(), + lastSeenAt: z.number().int().nonnegative(), +})).superRefine((value, ctx) => { + for (const [key, watermark] of Object.entries(value)) { + if (key !== clientSnapshotKey(watermark.deviceId, watermark.clientInstanceId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Tabs registry client revision key must match client identity', + path: [key], + }) + } + } +}) + +function resolveStoreDir(baseDir?: string): string { + if (baseDir) return path.resolve(baseDir) + return path.join(getFreshellConfigDir(), 'tabs-registry') +} + +function sha256(raw: string | Buffer): string { + return crypto.createHash('sha256').update(raw).digest('hex') +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]` + } + const entries = Object.entries(value as Record<string, unknown>) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(',')}}` +} + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(stableStringify(value), 'utf-8') +} + +function formatBytes(bytes: number): string { + if (bytes % (1024 * 1024) === 0) return `${bytes / (1024 * 1024)} MiB` + if (bytes % 1024 === 0) return `${bytes / 1024} KiB` + return `${bytes} bytes` +} + +function sourceKey(record: RegistryTabRecord): string { + return `${record.deviceId}:${record.clientInstanceId ?? ''}:${record.tabKey}:${record.status}:${record.tabId}` } -function isIncomingNewer(incoming: RegistryTabRecord, current: RegistryTabRecord | undefined): boolean { - if (!current) return true - if (incoming.revision !== current.revision) return incoming.revision > current.revision - if (incoming.updatedAt !== current.updatedAt) return incoming.updatedAt >= current.updatedAt - return true +export function compareRegistryRecordsByEventTime(a: RegistryTabRecord, b: RegistryTabRecord): number { + if (a.updatedAt !== b.updatedAt) return a.updatedAt - b.updatedAt + if (a.revision !== b.revision) return a.revision - b.revision + if (a.status !== b.status) return a.status === 'closed' ? 1 : -1 + return sourceKey(a).localeCompare(sourceKey(b)) +} + +function pickEventWinner(a: RegistryTabRecord | undefined, b: RegistryTabRecord): RegistryTabRecord { + if (!a) return b + return compareRegistryRecordsByEventTime(a, b) < 0 ? b : a } function sortByUpdatedDesc(a: RegistryTabRecord, b: RegistryTabRecord): number { @@ -41,112 +316,915 @@ function sortByClosedDesc(a: RegistryTabRecord, b: RegistryTabRecord): number { return bClosedAt - aClosedAt } -function resolveStoreDir(baseDir?: string): string { - if (baseDir) return path.resolve(baseDir) - return path.join(getFreshellConfigDir(), 'tabs-registry') +function clientSnapshotKey(deviceId: string, clientInstanceId: string): string { + if (!deviceId.trim() || !clientInstanceId.trim()) { + throw new Error('Tabs registry client snapshot requires non-empty deviceId and clientInstanceId') + } + const encode = (value: string) => Buffer.from(value, 'utf-8').toString('base64url') + return `${encode(deviceId)}:${encode(clientInstanceId)}` +} + +function assertClientSnapshotKeyMatchesSnapshot(key: string, snapshot: ClientOpenSnapshot): void { + const expected = clientSnapshotKey(snapshot.deviceId, snapshot.clientInstanceId) + if (key !== expected) { + throw new Error('Tabs registry compact state snapshot key does not match snapshot identity') + } +} + +function cloneState(state: CompactTabsRegistryStateV1, savedAt: number): CompactTabsRegistryStateV1 { + return { + ...state, + savedAt, + openSnapshotsByClient: { ...state.openSnapshotsByClient }, + clientRevisionsByClient: { ...state.clientRevisionsByClient }, + closedByTabKey: { ...state.closedByTabKey }, + devicesById: Object.fromEntries(Object.entries(state.devicesById).map(([key, device]) => [key, { ...device }])), + } +} + +function emptyState(now: number, maxClosedRetentionDays = DEFAULT_CLOSED_RETENTION_DAYS): CompactTabsRegistryStateV1 { + return { + version: 1, + savedAt: now, + openSnapshotTtlMinutes: DEFAULT_OPEN_SNAPSHOT_TTL_MINUTES, + deviceDisplayTtlDays: DEFAULT_DEVICE_DISPLAY_TTL_DAYS, + maxClosedRetentionDays, + openSnapshotsByClient: {}, + clientRevisionsByClient: {}, + closedByTabKey: {}, + devicesById: {}, + } +} + +function validateRetention(days: number): number { + if (!Number.isInteger(days) || days < 1 || days > 30) { + throw new Error('Closed tab retention must be an integer from 1 to 30 days') + } + return days +} + +function validateRecordCaps(records: RegistryTabRecord[], caps: TabsRegistryCaps): void { + if (records.length > caps.maxRecordsPerPush) { + throw new Error(`Tabs registry push can contain at most ${caps.maxRecordsPerPush} records`) + } + const seen = new Set<string>() + for (const record of records) { + if (seen.has(record.tabKey)) { + throw new Error(`Tabs registry push contains duplicate tab key: ${record.tabKey}`) + } + seen.add(record.tabKey) + validateRecordPaneCaps(record, caps) + } +} + +function validateRecordPaneCaps(record: RegistryTabRecord, caps: TabsRegistryCaps): void { + if (record.panes.length > caps.maxPanesPerRecord || record.paneCount > caps.maxPanesPerRecord) { + throw new Error(`Tabs registry record can contain at most ${caps.maxPanesPerRecord} panes`) + } } +function validateStateCaps(state: CompactTabsRegistryStateV1, caps: TabsRegistryCaps): void { + const snapshotCount = Object.keys(state.openSnapshotsByClient).length + if (snapshotCount > caps.maxClientSnapshotRefs) { + throw new Error(`Tabs registry can retain at most ${caps.maxClientSnapshotRefs} client snapshots`) + } + for (const snapshot of Object.values(state.openSnapshotsByClient)) { + if (snapshot.records.length > caps.maxOpenRecordsPerClientSnapshot) { + throw new Error(`Tabs registry client snapshot can contain at most ${caps.maxOpenRecordsPerClientSnapshot} open records`) + } + validateRecordCaps(snapshot.records, caps) + } + const closedCount = Object.keys(state.closedByTabKey).length + if (closedCount > caps.maxClosedTombstones) { + throw new Error(`Tabs registry can retain at most ${caps.maxClosedTombstones} closed tombstones`) + } + const revisionCount = Object.keys(state.clientRevisionsByClient).length + if (revisionCount > caps.maxClientRevisionWatermarks) { + throw new Error(`Tabs registry can retain at most ${caps.maxClientRevisionWatermarks} client revision watermarks`) + } + for (const record of Object.values(state.closedByTabKey)) { + validateRecordPaneCaps(record, caps) + } + const deviceCount = Object.keys(state.devicesById).length + if (deviceCount > caps.maxDevices) { + throw new Error(`Tabs registry can retain at most ${caps.maxDevices} devices`) + } + const stateBytes = jsonBytes(state) + if (stateBytes > caps.maxCompactStateBytes) { + throw new Error(`Tabs registry compact state exceeds ${formatBytes(caps.maxCompactStateBytes)}`) + } +} + +function pruneClosedTombstones( + closedByTabKey: Record<string, RegistryTabRecord>, + now: number, + maxClosedRetentionDays: number, + maxClosedTombstones: number, +): Record<string, RegistryTabRecord> { + const cutoff = now - maxClosedRetentionDays * DAY_MS + const retained = Object.values(closedByTabKey) + .filter((record) => (record.closedAt ?? record.updatedAt) >= cutoff) + .sort(sortByClosedDesc) + .slice(0, maxClosedTombstones) + return Object.fromEntries(retained.map((record) => [record.tabKey, record])) +} + +function applyQueuedMaintenance( + state: CompactTabsRegistryStateV1, + now: number, + caps: TabsRegistryCaps, +): CompactTabsRegistryStateV1 { + const openCutoff = now - state.openSnapshotTtlMinutes * MINUTE_MS + const deviceCutoff = now - state.deviceDisplayTtlDays * DAY_MS + const openSnapshotsByClient = Object.fromEntries( + Object.entries(state.openSnapshotsByClient) + .filter(([, snapshot]) => snapshot.snapshotReceivedAt >= openCutoff) + .sort(([, a], [, b]) => b.snapshotReceivedAt - a.snapshotReceivedAt) + ) + const clientRevisionsByClient = Object.fromEntries( + Object.entries(state.clientRevisionsByClient) + .filter(([, watermark]) => watermark.lastSeenAt >= deviceCutoff) + .sort(([, a], [, b]) => b.lastSeenAt - a.lastSeenAt) + .slice(0, caps.maxClientRevisionWatermarks), + ) + const closedByTabKey = pruneClosedTombstones( + state.closedByTabKey, + now, + state.maxClosedRetentionDays, + caps.maxClosedTombstones, + ) + const devicesById = Object.fromEntries( + Object.entries(state.devicesById) + .filter(([, device]) => device.lastSeenAt >= deviceCutoff) + .sort(([, a], [, b]) => b.lastSeenAt - a.lastSeenAt) + .slice(0, caps.maxDevices), + ) + return { + ...state, + savedAt: now, + openSnapshotsByClient, + clientRevisionsByClient, + closedByTabKey, + devicesById, + } +} + +function assertSnapshotRecordOwnership(input: ReplaceClientSnapshotInput, record: RegistryTabRecord): void { + if (record.deviceId !== input.deviceId || record.deviceLabel !== input.deviceLabel) { + throw new Error('Tabs registry record device metadata must match the snapshot device metadata') + } +} + +function buildSnapshotPayloadHash(snapshot: Pick<ClientOpenSnapshot, 'deviceId' | 'deviceLabel' | 'clientInstanceId' | 'snapshotRevision' | 'records'>): string { + return sha256(stableStringify({ + deviceId: snapshot.deviceId, + deviceLabel: snapshot.deviceLabel, + clientInstanceId: snapshot.clientInstanceId, + snapshotRevision: snapshot.snapshotRevision, + records: snapshot.records, + })) +} + +function buildClientRevisionWatermark(deviceId: string, clientInstanceId: string, snapshotRevision: number, lastSeenAt: number): ClientRevisionWatermark { + return { + deviceId, + clientInstanceId, + snapshotRevision, + lastSeenAt, + } +} + +function recordMapHasSameEntries<T extends object>(a: Record<string, T>, b: Record<string, T>): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + return aKeys.every((key) => a[key] === b[key]) +} + +function findOpenWinnerForTab( + openSnapshotsByClient: Record<string, ClientOpenSnapshot>, + tabKey: string, +): RegistryTabRecord | undefined { + let winner: RegistryTabRecord | undefined + for (const snapshot of Object.values(openSnapshotsByClient)) { + for (const record of snapshot.records) { + if (record.tabKey !== tabKey) continue + winner = pickEventWinner(winner, record) + } + } + return winner +} + +async function bestEffortFsyncFile(file: string): Promise<void> { + try { + const handle = await fsp.open(file, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } + } catch { + // Some filesystems used in tests do not support fsync consistently. + } +} + +async function bestEffortFsyncDir(dir: string): Promise<void> { + try { + const handle = await fsp.open(dir, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } + } catch { + // Directory fsync is best-effort across platforms. + } +} + +function archiveTimestamp(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0') + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join('') +} + +async function* readBoundedLegacyLines(legacyPath: string, maxLineBytes: number): AsyncGenerator<string> { + const input = fs.createReadStream(legacyPath, { encoding: 'utf-8', highWaterMark: 64 * 1024 }) + let pending = '' + let pendingBytes = 0 + + for await (const chunk of input) { + let remaining = String(chunk) + while (remaining.length > 0) { + const newlineIndex = remaining.indexOf('\n') + const segment = newlineIndex === -1 ? remaining : remaining.slice(0, newlineIndex) + const segmentBytes = Buffer.byteLength(segment, 'utf-8') + if (pendingBytes + segmentBytes > maxLineBytes) { + input.destroy() + throw new Error(`Tabs registry legacy migration cap exceeded: line is larger than ${formatBytes(maxLineBytes)}`) + } + pending += segment + pendingBytes += segmentBytes + if (newlineIndex === -1) { + break + } + yield pending.endsWith('\r') ? pending.slice(0, -1) : pending + pending = '' + pendingBytes = 0 + remaining = remaining.slice(newlineIndex + 1) + } + } + + if (pending.length > 0 || pendingBytes > 0) { + yield pending.endsWith('\r') ? pending.slice(0, -1) : pending + } +} + +type ManifestObjectRefs = Pick<TabsRegistryManifestV1, 'openSnapshots' | 'clientRevisions' | 'closedTombstones' | 'devices'> + export class TabsRegistryStore { - private readonly latestByTabKey = new Map<string, RegistryTabRecord>() - private readonly devices = new TabsDeviceStore() - private readonly logPath: string - private readonly now: () => number - private readonly defaultRangeDays: number + private state: CompactTabsRegistryStateV1 + private manifestRevision = 0 + private manifestObjectRefs?: ManifestObjectRefs private writeQueue: Promise<void> = Promise.resolve() + private readonly now: () => number + private readonly caps: TabsRegistryCaps + private failurePoint?: FailurePoint + private beforeManifestPublishHook?: () => Promise<void> + private afterManifestPublishHook?: () => Promise<void> - constructor(private readonly rootDir: string, options: TabsRegistryStoreOptions = {}) { - this.logPath = path.join(rootDir, 'tabs-registry.jsonl') + private constructor( + private readonly rootDir: string, + state: CompactTabsRegistryStateV1, + manifestRevision: number, + options: TabsRegistryStoreOptions = {}, + manifestObjectRefs?: ManifestObjectRefs, + ) { + this.state = state + this.manifestRevision = manifestRevision + this.manifestObjectRefs = manifestObjectRefs this.now = options.now ?? (() => Date.now()) - this.defaultRangeDays = options.defaultRangeDays ?? DEFAULT_RANGE_DAYS - this.hydrateFromDisk() + this.caps = { ...DEFAULT_CAPS, ...(options.caps ?? {}) } + } + + static async open(rootDir: string, options: TabsRegistryStoreOptions = {}): Promise<TabsRegistryStore> { + const resolvedRoot = resolveStoreDir(rootDir) + const caps = { ...DEFAULT_CAPS, ...(options.caps ?? {}) } + const now = options.now ?? (() => Date.now()) + await fsp.mkdir(path.join(resolvedRoot, 'v1', 'objects'), { recursive: true }) + await fsp.mkdir(path.join(resolvedRoot, 'v1', 'tmp'), { recursive: true }) + + const compactManifestPath = path.join(resolvedRoot, 'v1', 'manifest.json') + if (fs.existsSync(compactManifestPath)) { + const { state, manifestRevision, manifestObjectRefs } = await TabsRegistryStore.loadCompactState(resolvedRoot, caps) + return new TabsRegistryStore(resolvedRoot, state, manifestRevision, options, manifestObjectRefs) + } + + const legacyPath = path.join(resolvedRoot, 'tabs-registry.jsonl') + if (fs.existsSync(legacyPath)) { + const migrationStartedAt = now() + const state = await TabsRegistryStore.migrateLegacyJsonl(legacyPath, migrationStartedAt, caps, options.defaultClosedRetentionDays) + const store = new TabsRegistryStore(resolvedRoot, state, 0, options) + await store.commitState(state) + const archivePath = path.join(resolvedRoot, `tabs-registry.jsonl.migrated-${archiveTimestamp(new Date(migrationStartedAt))}`) + await fsp.rename(legacyPath, archivePath) + await bestEffortFsyncDir(resolvedRoot) + return store + } + + return new TabsRegistryStore( + resolvedRoot, + emptyState(now(), options.defaultClosedRetentionDays ?? DEFAULT_CLOSED_RETENTION_DAYS), + 0, + options, + ) } - private hydrateFromDisk(): void { - fs.mkdirSync(this.rootDir, { recursive: true }) - if (!fs.existsSync(this.logPath)) return + private static async loadCompactState(rootDir: string, caps: TabsRegistryCaps): Promise<{ + state: CompactTabsRegistryStateV1 + manifestRevision: number + manifestObjectRefs: ManifestObjectRefs + }> { + const manifestPath = path.join(rootDir, 'v1', 'manifest.json') + let manifest: TabsRegistryManifestV1 + try { + const manifestStat = await fsp.stat(manifestPath) + if (manifestStat.size > caps.maxSerializedManifestBytes) { + throw new Error(`Tabs registry compact state manifest exceeds ${formatBytes(caps.maxSerializedManifestBytes)}`) + } + const rawManifest = await fsp.readFile(manifestPath, 'utf-8') + if (Buffer.byteLength(rawManifest, 'utf-8') > caps.maxSerializedManifestBytes) { + throw new Error(`Tabs registry compact state manifest exceeds ${formatBytes(caps.maxSerializedManifestBytes)}`) + } + manifest = ManifestSchema.parse(JSON.parse(rawManifest)) + } catch (error) { + throw new Error(`Tabs registry compact state manifest is invalid: ${error instanceof Error ? error.message : String(error)}`) + } + + const readObject = async <T>(ref: ObjectRef, schema: z.ZodType<T>, maxBytes: number): Promise<T> => { + if (ref.bytes > maxBytes) { + throw new Error(`Tabs registry compact state object ${ref.path} exceeds ${formatBytes(maxBytes)}`) + } + const absolute = path.join(rootDir, 'v1', ref.path) + const stat = await fsp.stat(absolute) + if (stat.size !== ref.bytes) { + throw new Error(`Tabs registry compact state object size mismatch: ${ref.path}`) + } + const raw = await fsp.readFile(absolute, 'utf-8') + const bytes = Buffer.byteLength(raw, 'utf-8') + const digest = sha256(raw) + if (bytes !== ref.bytes || digest !== ref.sha256) { + throw new Error(`Tabs registry compact state object failed hash validation: ${ref.path}`) + } + return schema.parse(JSON.parse(raw)) + } - const raw = fs.readFileSync(this.logPath, 'utf-8') - for (const line of raw.split('\n')) { + const validateManifestRefsBeforeRead = (manifest: TabsRegistryManifestV1): void => { + const openRefs = Object.values(manifest.openSnapshots) + if (openRefs.length > caps.maxClientSnapshotRefs) { + throw new Error(`Tabs registry can retain at most ${caps.maxClientSnapshotRefs} client snapshots`) + } + for (const ref of openRefs) { + if (ref.bytes > caps.maxSerializedClientSnapshotObjectBytes) { + throw new Error(`Tabs registry compact state object ${ref.path} exceeds ${formatBytes(caps.maxSerializedClientSnapshotObjectBytes)}`) + } + } + const fixedRefs: Array<[ObjectRef, number]> = [ + [manifest.clientRevisions, caps.maxSerializedDeviceMetadataObjectBytes], + [manifest.closedTombstones, caps.maxSerializedClosedTombstoneObjectBytes], + [manifest.devices, caps.maxSerializedDeviceMetadataObjectBytes], + ] + for (const [ref, maxBytes] of fixedRefs) { + if (ref.bytes > maxBytes) { + throw new Error(`Tabs registry compact state object ${ref.path} exceeds ${formatBytes(maxBytes)}`) + } + } + const referencedBytes = [...openRefs, manifest.clientRevisions, manifest.closedTombstones, manifest.devices] + .reduce((sum, ref) => sum + ref.bytes, 0) + if (referencedBytes > caps.maxCompactStateBytes) { + throw new Error(`Tabs registry compact state exceeds ${formatBytes(caps.maxCompactStateBytes)}`) + } + } + + try { + validateManifestRefsBeforeRead(manifest) + const openEntries = await Promise.all(Object.entries(manifest.openSnapshots).map(async ([key, ref]) => { + const snapshot = await readObject(ref, ClientOpenSnapshotSchema, caps.maxSerializedClientSnapshotObjectBytes) + assertClientSnapshotKeyMatchesSnapshot(key, snapshot) + if (snapshot.openSnapshotPayloadHash !== buildSnapshotPayloadHash(snapshot)) { + throw new Error('Tabs registry compact state client snapshot payload hash does not match snapshot content') + } + return [key, snapshot] as const + })) + const state: CompactTabsRegistryStateV1 = { + version: 1, + savedAt: manifest.committedAt, + openSnapshotTtlMinutes: manifest.settings.openSnapshotTtlMinutes, + deviceDisplayTtlDays: manifest.settings.deviceDisplayTtlDays, + maxClosedRetentionDays: manifest.settings.maxClosedRetentionDays, + openSnapshotsByClient: Object.fromEntries(openEntries), + clientRevisionsByClient: await readObject(manifest.clientRevisions, ClientRevisionsSchema, caps.maxSerializedDeviceMetadataObjectBytes), + closedByTabKey: await readObject(manifest.closedTombstones, ClosedTombstonesSchema, caps.maxSerializedClosedTombstoneObjectBytes), + devicesById: await readObject(manifest.devices, DevicesSchema, caps.maxSerializedDeviceMetadataObjectBytes), + } + validateStateCaps(state, caps) + return { + state, + manifestRevision: manifest.manifestRevision, + manifestObjectRefs: { + openSnapshots: manifest.openSnapshots, + clientRevisions: manifest.clientRevisions, + closedTombstones: manifest.closedTombstones, + devices: manifest.devices, + }, + } + } catch (error) { + throw new Error(`Tabs registry compact state is invalid: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private static async migrateLegacyJsonl( + legacyPath: string, + migrationStartedAt: number, + caps: TabsRegistryCaps, + maxClosedRetentionDays = DEFAULT_CLOSED_RETENTION_DAYS, + ): Promise<CompactTabsRegistryStateV1> { + const latestByTabKey = new Map<string, RegistryTabRecord>() + let retainedBytes = 0 + + for await (const line of readBoundedLegacyLines(legacyPath, caps.maxLegacyLineBytes)) { const trimmed = line.trim() if (!trimmed) continue + let parsedJson: unknown try { - const parsed = TabRegistryRecordSchema.parse(JSON.parse(trimmed)) - this.applyRecord(parsed) + parsedJson = JSON.parse(trimmed) } catch { - // Ignore malformed history lines; valid lines still restore state. + continue + } + const parsedRecord = TabRegistryRecordSchema.safeParse(parsedJson) + if (!parsedRecord.success) continue + const record = parsedRecord.data + validateRecordCaps([record], caps) + const current = latestByTabKey.get(record.tabKey) + const winner = pickEventWinner(current, record) + if (winner !== current) { + retainedBytes -= current ? jsonBytes(current) : 0 + retainedBytes += jsonBytes(winner) + if (retainedBytes > caps.maxMigrationRetainedBytes) { + throw new Error(`Tabs registry legacy migration retained-byte cap exceeded: ${formatBytes(caps.maxMigrationRetainedBytes)}`) + } + latestByTabKey.set(record.tabKey, winner) + } + if (latestByTabKey.size > caps.maxLegacyUniqueTabKeys) { + throw new Error(`Tabs registry legacy migration cap exceeded: more than ${caps.maxLegacyUniqueTabKeys} unique tab keys`) + } + } + + const state = emptyState(migrationStartedAt, maxClosedRetentionDays) + const openByDevice = new Map<string, RegistryTabRecord[]>() + const closedCutoff = migrationStartedAt - maxClosedRetentionDays * DAY_MS + + for (const record of latestByTabKey.values()) { + if (record.status === 'closed') { + if ((record.closedAt ?? record.updatedAt) >= closedCutoff) { + state.closedByTabKey[record.tabKey] = record + } + continue + } + const records = openByDevice.get(record.deviceId) ?? [] + records.push(record) + openByDevice.set(record.deviceId, records) + state.devicesById[record.deviceId] = { + deviceId: record.deviceId, + deviceLabel: record.deviceLabel, + lastSeenAt: migrationStartedAt, + } + } + + for (const [deviceId, records] of openByDevice) { + if (openByDevice.size > caps.maxClientSnapshotRefs) { + throw new Error(`Tabs registry legacy migration cap exceeded: more than ${caps.maxClientSnapshotRefs} migrated open snapshots`) + } + if (records.length > caps.maxOpenRecordsPerClientSnapshot) { + throw new Error(`Tabs registry legacy migration cap exceeded: client snapshot has more than ${caps.maxOpenRecordsPerClientSnapshot} open records`) + } + const deviceLabel = records[0]?.deviceLabel ?? deviceId + const snapshotRecords = records.map((record) => ({ ...record, deviceLabel, clientInstanceId: 'legacy-migration' })) + const openSnapshotPayloadHash = buildSnapshotPayloadHash({ + deviceId, + deviceLabel, + clientInstanceId: 'legacy-migration', + snapshotRevision: 1, + records: snapshotRecords, + }) + const snapshot: ClientOpenSnapshot = { + deviceId, + deviceLabel, + clientInstanceId: 'legacy-migration', + snapshotRevision: 1, + lastPushPayloadHash: openSnapshotPayloadHash, + openSnapshotPayloadHash, + snapshotReceivedAt: migrationStartedAt, + records: snapshotRecords, + } + state.openSnapshotsByClient[clientSnapshotKey(deviceId, 'legacy-migration')] = snapshot + state.clientRevisionsByClient[clientSnapshotKey(deviceId, 'legacy-migration')] = buildClientRevisionWatermark( + deviceId, + 'legacy-migration', + 1, + migrationStartedAt, + ) + } + + const maintained = applyQueuedMaintenance(state, migrationStartedAt, caps) + validateStateCaps(maintained, caps) + return maintained + } + + setTestFailurePoint(point: FailurePoint | undefined): void { + this.failurePoint = point + } + + setTestBeforeManifestPublishHook(hook: (() => Promise<void>) | undefined): void { + this.beforeManifestPublishHook = hook + } + + setTestAfterManifestPublishHook(hook: (() => Promise<void>) | undefined): void { + this.afterManifestPublishHook = hook + } + + private maybeFail(point: FailurePoint): void { + if (this.failurePoint === point) { + this.failurePoint = undefined + throw new Error(`Injected tabs registry ${point} failure`) + } + } + + private async writeObject(value: unknown, maxBytes: number): Promise<ObjectRef> { + const raw = stableStringify(value) + const bytes = Buffer.byteLength(raw, 'utf-8') + if (bytes > maxBytes) { + throw new Error(`Tabs registry object exceeds ${formatBytes(maxBytes)}`) + } + const digest = sha256(raw) + const relativePath = `objects/${digest}.json` + const objectPath = path.join(this.rootDir, 'v1', relativePath) + if (fs.existsSync(objectPath)) { + const existing = await fsp.readFile(objectPath, 'utf-8') + if (Buffer.byteLength(existing, 'utf-8') !== bytes || sha256(existing) !== digest) { + throw new Error(`Tabs registry existing compact object failed hash validation: ${relativePath}`) } + return { path: relativePath, sha256: digest, bytes } } + + const tmpPath = path.join(this.rootDir, 'v1', 'tmp', `${digest}.${process.pid}.${Date.now()}.tmp`) + this.maybeFail('object-write') + await fsp.writeFile(tmpPath, raw, 'utf-8') + await bestEffortFsyncFile(tmpPath) + this.maybeFail('object-rename') + await fsp.rename(tmpPath, objectPath).catch(async (error: NodeJS.ErrnoException) => { + if (error.code === 'EEXIST') { + await fsp.rm(tmpPath, { force: true }) + return + } + throw error + }) + await bestEffortFsyncDir(path.dirname(objectPath)) + return { path: relativePath, sha256: digest, bytes } } - private applyRecord(record: RegistryTabRecord): void { - const current = this.latestByTabKey.get(record.tabKey) - if (!isIncomingNewer(record, current)) return - this.latestByTabKey.set(record.tabKey, record) - this.devices.upsert(record.deviceId, record.deviceLabel, record.updatedAt) + private async buildManifest(state: CompactTabsRegistryStateV1): Promise<TabsRegistryManifestV1> { + const openSnapshots: Record<string, ObjectRef> = {} + for (const [key, snapshot] of Object.entries(state.openSnapshotsByClient)) { + const previousSnapshot = this.state.openSnapshotsByClient[key] + const previousRef = this.manifestObjectRefs?.openSnapshots[key] + openSnapshots[key] = previousRef && previousSnapshot === snapshot + ? previousRef + : await this.writeObject(snapshot, this.caps.maxSerializedClientSnapshotObjectBytes) + } + const closedTombstones = this.manifestObjectRefs?.closedTombstones + && recordMapHasSameEntries(this.state.closedByTabKey, state.closedByTabKey) + ? this.manifestObjectRefs.closedTombstones + : await this.writeObject(state.closedByTabKey, this.caps.maxSerializedClosedTombstoneObjectBytes) + const clientRevisions = this.manifestObjectRefs?.clientRevisions + && recordMapHasSameEntries(this.state.clientRevisionsByClient, state.clientRevisionsByClient) + ? this.manifestObjectRefs.clientRevisions + : await this.writeObject(state.clientRevisionsByClient, this.caps.maxSerializedDeviceMetadataObjectBytes) + const devices = this.manifestObjectRefs?.devices + && recordMapHasSameEntries(this.state.devicesById, state.devicesById) + ? this.manifestObjectRefs.devices + : await this.writeObject(state.devicesById, this.caps.maxSerializedDeviceMetadataObjectBytes) + return { + version: 1, + manifestRevision: this.manifestRevision + 1, + committedAt: state.savedAt, + openSnapshots, + clientRevisions, + closedTombstones, + devices, + settings: { + openSnapshotTtlMinutes: state.openSnapshotTtlMinutes, + deviceDisplayTtlDays: state.deviceDisplayTtlDays, + maxClosedRetentionDays: state.maxClosedRetentionDays, + }, + } } - private async appendRecord(record: RegistryTabRecord): Promise<void> { - await fsp.mkdir(this.rootDir, { recursive: true }) - await fsp.appendFile(this.logPath, `${JSON.stringify(record)}\n`, 'utf-8') + private async publishManifest(manifest: TabsRegistryManifestV1): Promise<void> { + const manifestPath = path.join(this.rootDir, 'v1', 'manifest.json') + const tmpPath = path.join(this.rootDir, 'v1', 'manifest.json.tmp') + const raw = stableStringify(manifest) + await this.beforeManifestPublishHook?.() + this.maybeFail('manifest-write') + await fsp.writeFile(tmpPath, raw, 'utf-8') + await bestEffortFsyncFile(tmpPath) + this.maybeFail('manifest-rename') + await fsp.rename(tmpPath, manifestPath) + await bestEffortFsyncDir(path.dirname(manifestPath)) + await this.afterManifestPublishHook?.() } - async upsert(record: RegistryTabRecord): Promise<boolean> { - const parsed = TabRegistryRecordSchema.parse(record) - let changed = false + private async garbageCollectObjects(manifest: TabsRegistryManifestV1): Promise<void> { + const referenced = new Set<string>([ + manifest.closedTombstones.path, + manifest.devices.path, + manifest.clientRevisions.path, + ...Object.values(manifest.openSnapshots).map((ref) => ref.path), + ]) + const objectsDir = path.join(this.rootDir, 'v1', 'objects') + const tmpDir = path.join(this.rootDir, 'v1', 'tmp') + await fsp.mkdir(objectsDir, { recursive: true }) + await fsp.mkdir(tmpDir, { recursive: true }) + for (const file of await fsp.readdir(objectsDir)) { + const relative = `objects/${file}` + if (!referenced.has(relative)) { + await fsp.rm(path.join(objectsDir, file), { force: true }) + } + } + for (const file of await fsp.readdir(tmpDir)) { + await fsp.rm(path.join(tmpDir, file), { force: true, recursive: true }) + } + } - this.writeQueue = this.writeQueue.then(async () => { - const current = this.latestByTabKey.get(parsed.tabKey) - if (!isIncomingNewer(parsed, current)) return - this.applyRecord(parsed) - await this.appendRecord(parsed) - changed = true + private async commitState(nextState: CompactTabsRegistryStateV1): Promise<TabsRegistryManifestV1> { + await fsp.mkdir(path.join(this.rootDir, 'v1', 'objects'), { recursive: true }) + await fsp.mkdir(path.join(this.rootDir, 'v1', 'tmp'), { recursive: true }) + validateStateCaps(nextState, this.caps) + const manifest = await this.buildManifest(nextState) + await this.publishManifest(manifest) + this.state = nextState + this.manifestRevision = manifest.manifestRevision + this.manifestObjectRefs = { + openSnapshots: manifest.openSnapshots, + clientRevisions: manifest.clientRevisions, + closedTombstones: manifest.closedTombstones, + devices: manifest.devices, + } + await this.garbageCollectObjects(manifest).catch((error) => { + // The manifest has been published and live state has been swapped. Surface + // maintenance failures without turning an already-committed mutation into + // a failed write. + console.warn(`Tabs registry garbage collection failed: ${error instanceof Error ? error.message : String(error)}`) }) + return manifest + } - await this.writeQueue - return changed + private enqueueMutation<T>(mutate: () => Promise<T>): Promise<T> { + const run = this.writeQueue.then(mutate, mutate) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + async replaceClientSnapshot(input: ReplaceClientSnapshotInput): Promise<{ + accepted: boolean + openRecords: number + closedRecords: number + }> { + const receiptTime = this.now() + const parsedRecords = input.records.map((record) => TabRegistryRecordSchema.parse(record)) + validateRecordCaps(parsedRecords, this.caps) + const pushBytes = jsonBytes({ ...input, records: parsedRecords }) + if (pushBytes > this.caps.maxSerializedPushBytes) { + throw new Error(`Tabs registry push payload exceeds ${formatBytes(this.caps.maxSerializedPushBytes)}`) + } + + const canonicalRecords = parsedRecords.map((record) => ({ ...record, clientInstanceId: input.clientInstanceId })) + const openRecords = canonicalRecords.filter((record) => record.status === 'open') + const closedRecords = canonicalRecords.filter((record) => record.status === 'closed') + if (openRecords.length > this.caps.maxOpenRecordsPerClientSnapshot) { + throw new Error(`Tabs registry client snapshot can contain at most ${this.caps.maxOpenRecordsPerClientSnapshot} open records`) + } + if (closedRecords.length > this.caps.maxClosedRecordsPerPush) { + throw new Error(`Tabs registry push can contain at most ${this.caps.maxClosedRecordsPerPush} closed records`) + } + for (const record of parsedRecords) { + assertSnapshotRecordOwnership(input, record) + } + + const key = clientSnapshotKey(input.deviceId, input.clientInstanceId) + const pushHash = buildSnapshotPayloadHash({ + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + records: canonicalRecords, + }) + const openSnapshotPayloadHash = buildSnapshotPayloadHash({ + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + records: openRecords, + }) + + return this.enqueueMutation(async () => { + const current = this.state.openSnapshotsByClient[key] + const watermark = this.state.clientRevisionsByClient[key] + const highWaterRevision = Math.max(current?.snapshotRevision ?? -1, watermark?.snapshotRevision ?? -1) + if (input.snapshotRevision < highWaterRevision) { + throw new Error('Stale snapshot revision rejected for tabs registry client snapshot') + } + if (current) { + if (input.snapshotRevision === current.snapshotRevision) { + if (pushHash !== current.lastPushPayloadHash) { + throw new Error('Duplicate snapshot revision has different tabs registry content') + } + return { accepted: true, openRecords: openRecords.length, closedRecords: closedRecords.length } + } + } else if (watermark && input.snapshotRevision <= watermark.snapshotRevision) { + throw new Error('Stale snapshot revision rejected for tabs registry client snapshot') + } + + let next = cloneState(this.state, receiptTime) + for (const closedRecord of closedRecords) { + const openWinner = findOpenWinnerForTab(next.openSnapshotsByClient, closedRecord.tabKey) + if (openWinner && compareRegistryRecordsByEventTime(openWinner, closedRecord) > 0) { + continue + } + next.closedByTabKey[closedRecord.tabKey] = pickEventWinner(next.closedByTabKey[closedRecord.tabKey], closedRecord) + } + + for (const openRecord of openRecords) { + const closed = next.closedByTabKey[openRecord.tabKey] + if (closed && compareRegistryRecordsByEventTime(closed, openRecord) < 0) { + delete next.closedByTabKey[openRecord.tabKey] + } + } + + next.openSnapshotsByClient[key] = { + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + lastPushPayloadHash: pushHash, + openSnapshotPayloadHash, + snapshotReceivedAt: receiptTime, + records: openRecords, + } + next.clientRevisionsByClient[key] = buildClientRevisionWatermark( + input.deviceId, + input.clientInstanceId, + input.snapshotRevision, + receiptTime, + ) + next.devicesById[input.deviceId] = { + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + lastSeenAt: receiptTime, + } + next = applyQueuedMaintenance(next, receiptTime, this.caps) + await this.commitState(next) + return { accepted: true, openRecords: openRecords.length, closedRecords: closedRecords.length } + }) + } + + async retireClientSnapshot(input: RetireClientSnapshotInput): Promise<{ accepted: boolean }> { + const receiptTime = this.now() + const key = clientSnapshotKey(input.deviceId, input.clientInstanceId) + return this.enqueueMutation(async () => { + const current = this.state.openSnapshotsByClient[key] + const watermark = this.state.clientRevisionsByClient[key] + if (!current) { + if (watermark && input.snapshotRevision <= watermark.snapshotRevision) return { accepted: false } + let next = cloneState(this.state, receiptTime) + next.clientRevisionsByClient[key] = buildClientRevisionWatermark( + input.deviceId, + input.clientInstanceId, + input.snapshotRevision, + receiptTime, + ) + const existingDevice = this.state.devicesById[input.deviceId] + next.devicesById[input.deviceId] = { + deviceId: input.deviceId, + deviceLabel: existingDevice?.deviceLabel ?? input.deviceId, + lastSeenAt: receiptTime, + } + next = applyQueuedMaintenance(next, receiptTime, this.caps) + await this.commitState(next) + return { accepted: true } + } + if (input.snapshotRevision <= current.snapshotRevision) return { accepted: false } + + let next = cloneState(this.state, receiptTime) + delete next.openSnapshotsByClient[key] + next.clientRevisionsByClient[key] = buildClientRevisionWatermark( + current.deviceId, + current.clientInstanceId, + input.snapshotRevision, + receiptTime, + ) + next.devicesById[input.deviceId] = { + deviceId: current.deviceId, + deviceLabel: current.deviceLabel, + lastSeenAt: receiptTime, + } + next = applyQueuedMaintenance(next, receiptTime, this.caps) + await this.commitState(next) + return { accepted: true } + }) } async query(input: TabsRegistryQueryInput): Promise<TabsRegistryQueryResult> { - const rangeDays = input.rangeDays ?? this.defaultRangeDays - const rangeMs = Math.max(1, rangeDays) * DAY_MS - const cutoff = this.now() - rangeMs + const closedTabRetentionDays = validateRetention(input.closedTabRetentionDays) + const now = this.now() + const openCutoff = now - this.state.openSnapshotTtlMinutes * MINUTE_MS + const closedDisplayCutoff = now - closedTabRetentionDays * DAY_MS + const closedServerCutoff = now - this.state.maxClosedRetentionDays * DAY_MS + + const winners = new Map<string, { record: RegistryTabRecord; snapshot?: ClientOpenSnapshot }>() + + for (const snapshot of Object.values(this.state.openSnapshotsByClient)) { + if (snapshot.snapshotReceivedAt < openCutoff) continue + for (const record of snapshot.records) { + const current = winners.get(record.tabKey) + if (!current || compareRegistryRecordsByEventTime(current.record, record) < 0) { + winners.set(record.tabKey, { record, snapshot }) + } + } + } + + for (const record of Object.values(this.state.closedByTabKey)) { + if ((record.closedAt ?? record.updatedAt) < closedServerCutoff) continue + const current = winners.get(record.tabKey) + if (!current || compareRegistryRecordsByEventTime(current.record, record) < 0) { + winners.set(record.tabKey, { record }) + } + } const localOpen: RegistryTabRecord[] = [] + const sameDeviceOpen: RegistryTabRecord[] = [] const remoteOpen: RegistryTabRecord[] = [] const closed: RegistryTabRecord[] = [] - for (const record of this.latestByTabKey.values()) { - if (record.status === 'open') { - if (record.deviceId === input.deviceId) { - localOpen.push(record) - } else { - remoteOpen.push(record) + for (const winner of winners.values()) { + const { record, snapshot } = winner + if (record.status === 'closed') { + if ((record.closedAt ?? record.updatedAt) >= closedDisplayCutoff) { + closed.push(record) } continue } - - const closedAt = record.closedAt ?? record.updatedAt - if (closedAt >= cutoff) { - closed.push(record) + if (record.deviceId === input.deviceId && snapshot?.clientInstanceId === input.clientInstanceId) { + localOpen.push(record) + } else if (record.deviceId === input.deviceId) { + sameDeviceOpen.push(record) + } else { + remoteOpen.push(record) } } return { localOpen: localOpen.sort(sortByUpdatedDesc), + sameDeviceOpen: sameDeviceOpen.sort(sortByUpdatedDesc), remoteOpen: remoteOpen.sort(sortByUpdatedDesc), closed: closed.sort(sortByClosedDesc), + devices: this.listDevices(), } } - listDevices(): Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> { - return this.devices.list() + listDevices(): RegistryDeviceEntry[] { + const now = this.now() + const cutoff = now - this.state.deviceDisplayTtlDays * DAY_MS + return Object.values(this.state.devicesById) + .filter((device) => device.lastSeenAt >= cutoff) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt) } count(): number { - return this.latestByTabKey.size + return Object.values(this.state.openSnapshotsByClient).reduce((sum, snapshot) => sum + snapshot.records.length, 0) + + Object.keys(this.state.closedByTabKey).length } } -export function createTabsRegistryStore(baseDir?: string, options: TabsRegistryStoreOptions = {}): TabsRegistryStore { - return new TabsRegistryStore(resolveStoreDir(baseDir), options) +export async function createTabsRegistryStore( + baseDir?: string, + options: TabsRegistryStoreOptions = {}, +): Promise<TabsRegistryStore> { + return TabsRegistryStore.open(resolveStoreDir(baseDir), options) } diff --git a/server/tabs-registry/types.ts b/server/tabs-registry/types.ts index 89592e925..435ba329f 100644 --- a/server/tabs-registry/types.ts +++ b/server/tabs-registry/types.ts @@ -10,6 +10,7 @@ export const RegistryPaneKindSchema = z.enum([ 'picker', 'claude-chat', 'agent-chat', + 'fresh-agent', 'extension', ]) export type RegistryPaneKind = z.infer<typeof RegistryPaneKindSchema> @@ -28,6 +29,7 @@ export const TabRegistryRecordBaseSchema = z.object({ serverInstanceId: z.string().min(1), deviceId: z.string().min(1), deviceLabel: z.string().min(1), + clientInstanceId: z.string().min(1).optional(), tabName: z.string().min(1), status: RegistryTabStatusSchema, revision: z.number().int().nonnegative(), diff --git a/server/terminal-metadata-service.ts b/server/terminal-metadata-service.ts index fe64057fa..c1f25cfac 100644 --- a/server/terminal-metadata-service.ts +++ b/server/terminal-metadata-service.ts @@ -9,7 +9,7 @@ import { type TerminalProvider = Exclude<TerminalMode, 'shell'> -type TerminalSeedRecord = { +export type TerminalSeedRecord = { terminalId: string mode: TerminalMode resumeSessionId?: string @@ -162,6 +162,24 @@ export class TerminalMetadataService { return this.commitIfChanged(next) } + clearSessionAssociation( + terminalId: string, + provider: CodingCliProviderName, + sessionId: string, + ): TerminalMeta | undefined { + const current = this.byTerminalId.get(terminalId) + if (!current) return undefined + if (current.provider !== provider || current.sessionId !== sessionId) return undefined + + const next: TerminalMeta = { + ...current, + provider: undefined, + sessionId: undefined, + } + + return this.commitIfChanged(next) + } + async applySessionMetadata(terminalId: string, session: CodingCliSession): Promise<TerminalMeta | undefined> { const current = this.byTerminalId.get(terminalId) if (!current) return undefined diff --git a/server/terminal-registry.ts b/server/terminal-registry.ts index 8e0395bda..c0b6fe53b 100644 --- a/server/terminal-registry.ts +++ b/server/terminal-registry.ts @@ -9,6 +9,12 @@ import { EventEmitter } from 'events' import { logger } from './logger.js' import { getPerfConfig, logPerfEvent, shouldLog, startPerfTimer } from './perf-logger.js' import type { ServerSettings } from '../shared/settings.js' +import { + CODEX_DURABILITY_SCHEMA_VERSION, + type CodexCandidateSource, + type CodexDurabilityRef, + type CodexDurabilityStoreRecord, +} from '../shared/codex-durability.js' import { convertWindowsPathToWslPath, isReachableDirectorySync } from './path-utils.js' import { isValidClaudeSessionId } from './claude-session-id.js' import type { LoopbackServerEndpoint } from './local-port.js' @@ -24,8 +30,16 @@ import type { } from './terminal-stream/registry-events.js' import { getOpencodeEnvOverrides, resolveOpencodeLaunchModel } from './opencode-launch.js' import { generateMcpInjection, cleanupMcpConfig } from './mcp/config-writer.js' +import { CODEX_MANAGED_REMOTE_CONFIG_ARGS } from './coding-cli/codex-managed-config.js' import type { CodexLaunchPlan, CodexLaunchSidecar } from './coding-cli/codex-app-server/launch-planner.js' import { isCodexSidecarTeardownError } from './coding-cli/codex-app-server/launch-planner.js' +import { + CodexDurabilityStore, + type CodexDurabilityRestoreLocator, +} from './coding-cli/codex-app-server/durability-store.js' +import { proofCodexRollout } from './coding-cli/codex-app-server/durability-proof.js' +import type { CodexRemoteProxyCandidate } from './coding-cli/codex-app-server/remote-proxy.js' +import type { CodexTurnEvent } from './coding-cli/codex-app-server/client.js' import { collectShutdownFailures, throwShutdownFailures } from './shutdown-join.js' import { recordSessionLifecycleEvent } from './session-observability.js' @@ -134,11 +148,7 @@ function providerNotificationArgs( if (mode === 'codex') { return { - args: [ - '-c', 'tui.notification_method=bel', - '-c', "tui.notifications=['agent-turn-complete']", - ...mcpInjection.args, - ], + args: mcpInjection.args, env: mcpInjection.env, } } @@ -193,8 +203,11 @@ export type CodexRecoveryLaunchInput = { export type CodexRecoveryOptions = { planCreate(input: CodexRecoveryLaunchInput): Promise<CodexLaunchPlan> retryDelayMs?: number - readinessTimeoutMs?: number - readinessPollMs?: number +} + +export type CodexDurabilityRestoreRecord = { + terminalId: string + durability: CodexDurabilityRef } function resolveCodingCliCommand( @@ -228,7 +241,7 @@ function resolveCodingCliCommand( if (parsed.protocol !== 'ws:' || parsed.hostname !== '127.0.0.1') { throw new Error('Codex launch requires a loopback app-server websocket URL.') } - remoteArgs.push('--remote', wsUrl) + remoteArgs.push('--remote', wsUrl, ...CODEX_MANAGED_REMOTE_CONFIG_ARGS) } let resumeArgs: string[] = [] if (resumeSessionId) { @@ -258,7 +271,9 @@ function resolveCodingCliCommand( ) } const effectiveModel = mode === 'opencode' - ? resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv }) + ? (resumeSessionId + ? undefined + : resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv })) : providerSettings?.model if (effectiveModel && spec.modelArgs) { settingsArgs.push(...spec.modelArgs(effectiveModel)) @@ -451,11 +466,30 @@ export type TerminalRecord = { lastInputToOutputMs?: number maxInputToOutputMs: number } - codexSidecar?: Pick<CodexLaunchSidecar, 'shutdown' | 'onLifecycleLoss'> + codexSidecar?: Pick< + CodexLaunchSidecar, + | 'shutdown' + | 'onLifecycleLoss' + | 'onCandidate' + | 'onTurnStarted' + | 'onTurnCompleted' + | 'onRepairTrigger' + | 'onFsChanged' + | 'watchPath' + | 'unwatchPath' + | 'markCandidatePersisted' + > codexSidecarLifecycleUnsubscribe?: () => void codexSidecarLifecyclePublished?: boolean codexSidecarPrePublicationLoss?: unknown codexSidecarGeneration?: number + codexRolloutWatch?: { watchId: string; rolloutPath: string } + codexDurability?: CodexDurabilityRef + codexDurabilityProof?: { + inFlight?: Promise<void> + rerunRequested?: boolean + } + codexInputGate?: { state: 'identity_pending' } codexRecovery?: CodexRecoveryOptions codexRecoveryAttempt?: Promise<void> codexRecoveryRetry?: { timer: NodeJS.Timeout; resolve: () => void } @@ -464,10 +498,27 @@ export type TerminalRecord = { codexRecoveryRetiringPty?: pty.IPty } +export type TerminalInputResult = + | { status: 'written' } + | { status: 'blocked_codex_identity_pending'; terminalId: string } + | { status: 'blocked_codex_identity_capture_timeout'; terminalId: string } + | { status: 'blocked_codex_identity_unavailable'; terminalId: string; reason?: string } + | { status: 'blocked_codex_recovery_pending'; terminalId: string } + | { status: 'no_terminal' } + | { status: 'not_running' } + +function isCodexStartupTerminalControlInput(data: string): boolean { + if (data.length === 0 || data.length > 128) return false + if (data === '\x1b[I' || data === '\x1b[O') return true + if (/^\x1b\[\d{1,4};\d{1,4}R$/.test(data)) return true + if (/^\x1b\[(?:\?|\>)?[\d;]{0,32}c$/.test(data)) return true + return /^\x1b\](?:10|11|12|4;\d{1,3});rgb:[0-9a-fA-F]{1,4}\/[0-9a-fA-F]{1,4}\/[0-9a-fA-F]{1,4}(?:\x07|\x1b\\)$/.test(data) +} + export type BindSessionResult = | { ok: true; terminalId: string; sessionId: string } | { ok: false; reason: 'terminal_missing' | 'mode_mismatch' | 'invalid_session_id' | 'terminal_not_running' } - | BindResult + | Extract<BindResult, { ok: false }> export type RepairLegacySessionOwnersResult = { repaired: boolean @@ -475,6 +526,11 @@ export type RepairLegacySessionOwnersResult = { clearedTerminalIds: string[] } +type TerminalRegistryOptions = { + codexDurabilityStore?: CodexDurabilityStore + serverInstanceId?: string +} + export class ChunkRingBuffer { private chunks: string[] = [] private size = 0 @@ -827,6 +883,8 @@ export function buildSpawnSpec( ALLOWED_ORIGINS: _allowedOrigins, NODE_ENV: _nodeEnv, npm_lifecycle_script: _npmLifecycleScript, + OPENCODE_SERVER_USERNAME: _opencodeServerUsername, + OPENCODE_SERVER_PASSWORD: _opencodeServerPassword, ...parentEnv } = process.env const env = { @@ -1006,11 +1064,19 @@ export class TerminalRegistry extends EventEmitter { private scrollbackMaxChars: number private maxPendingSnapshotChars: number private sidecarShutdowns = new Map<string, SidecarShutdownEntry>() + private codexDurabilityStore: CodexDurabilityStore + private codexCandidatePersistenceQueues = new Map<string, Promise<void>>() + private serverInstanceId: string // Legacy transport batching path. Broker cutover destination: // - outputBuffers/flush timers/mobile batching -> broker client-output queue. private outputBuffers = new Map<WebSocket, PendingOutput>() - constructor(settings?: ServerSettings, maxTerminals?: number, maxExitedTerminals?: number) { + constructor( + settings?: ServerSettings, + maxTerminals?: number, + maxExitedTerminals?: number, + options: TerminalRegistryOptions = {}, + ) { super() // Permanent terminal.exit listeners: index, ws-handler, broker, codex-wiring, // terminal-view. Shutdown uses a single shared listener (no per-terminal scaling). @@ -1018,6 +1084,8 @@ export class TerminalRegistry extends EventEmitter { this.settings = settings this.maxTerminals = maxTerminals ?? MAX_TERMINALS this.maxExitedTerminals = maxExitedTerminals ?? Number(process.env.MAX_EXITED_TERMINALS || 200) + this.codexDurabilityStore = options.codexDurabilityStore ?? new CodexDurabilityStore() + this.serverInstanceId = options.serverInstanceId?.trim() || process.env.FRESHELL_SERVER_INSTANCE_ID || `srv-${process.pid}` this.scrollbackMaxChars = this.computeScrollbackMaxChars(settings) { const raw = Number(process.env.MAX_PENDING_SNAPSHOT_CHARS || DEFAULT_MAX_PENDING_SNAPSHOT_CHARS) @@ -1027,6 +1095,12 @@ export class TerminalRegistry extends EventEmitter { this.startPerfMonitor() } + setServerInstanceId(serverInstanceId: string): void { + const normalized = serverInstanceId.trim() + if (!normalized) return + this.serverInstanceId = normalized + } + setSettings(settings: ServerSettings) { this.settings = settings this.scrollbackMaxChars = this.computeScrollbackMaxChars(settings) @@ -1156,7 +1230,11 @@ export class TerminalRegistry extends EventEmitter { exitCode: number | undefined, reason: 'pty_exit' | 'user_final_close', ): void { - if (record.mode === 'shell' || record.resumeSessionId) { + if ( + record.mode === 'shell' + || record.resumeSessionId + || (record.mode === 'codex' && record.codexDurability?.state === 'durable' && record.codexDurability.durableThreadId) + ) { return } const ptyPid = record.pty.pid @@ -1171,6 +1249,40 @@ export class TerminalRegistry extends EventEmitter { }) } + private forgetCodexDurabilityStoreRecord(record: TerminalRecord, reason: string): void { + if (record.mode !== 'codex') return + if (!record.codexDurability) return + void this.codexDurabilityStore.delete(record.terminalId).catch((err) => { + logger.warn({ err, terminalId: record.terminalId, reason }, 'Failed to delete Codex durability store record') + }) + } + + private finishTerminalPtyExit( + record: TerminalRecord, + event: { exitCode: number; signal?: number }, + ): void { + this.markCodexRecoveryFinalClose(record) + record.status = 'exited' + record.exitCode = event.exitCode + const now = Date.now() + record.lastActivityAt = now + record.exitedAt = now + cleanupMcpConfig(record.terminalId, record.mode, record.mcpCwd) + for (const client of record.clients) { + this.flushOutputBuffer(client) + this.safeSend(client, { type: 'terminal.exit', terminalId: record.terminalId, exitCode: event.exitCode }, { terminalId: record.terminalId, perf: record.perf }) + } + record.clients.clear() + record.suppressedOutputClients.clear() + record.pendingSnapshotClients.clear() + this.releaseBinding(record.terminalId, 'exit') + this.emit('terminal.exit', { terminalId: record.terminalId, exitCode: event.exitCode }) + this.recordTerminalExitWithoutDurableSession(record, event.exitCode, 'pty_exit') + this.forgetCodexDurabilityStoreRecord(record, 'pty_exit') + void this.releaseCodexSidecar(record).catch(() => undefined) + this.reapExitedTerminals() + } + private reapExitedTerminals(): void { const max = this.maxExitedTerminals if (!max || max <= 0) return @@ -1182,7 +1294,9 @@ export class TerminalRegistry extends EventEmitter { const excess = exited.length - max if (excess <= 0) return for (let i = 0; i < excess; i += 1) { - this.terminals.delete(exited[i].terminalId) + const terminal = exited[i] + this.terminals.delete(terminal.terminalId) + this.forgetCodexDurabilityStoreRecord(terminal, 'reap_exited') } } @@ -1270,6 +1384,14 @@ export class TerminalRegistry extends EventEmitter { const title = getModeLabel(opts.mode) + const initialCodexDurability: CodexDurabilityRef | undefined = opts.mode === 'codex' && resumeForBinding + ? { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: resumeForBinding, + } + : undefined + const record: TerminalRecord = { terminalId, title, @@ -1297,6 +1419,10 @@ export class TerminalRegistry extends EventEmitter { ? !opts.providerSettings?.codexAppServer?.deferLifecycleUntilPublished : undefined, codexSidecarGeneration: opts.mode === 'codex' ? 0 : undefined, + codexDurability: initialCodexDurability, + codexInputGate: opts.mode === 'codex' && !resumeForBinding + ? { state: 'identity_pending' } + : undefined, codexRecovery: opts.mode === 'codex' ? opts.providerSettings?.codexAppServer?.recovery : undefined, perf: perfConfig.enabled ? { @@ -1396,28 +1522,39 @@ export class TerminalRegistry extends EventEmitter { if (record.status === 'exited') { return } - this.markCodexRecoveryFinalClose(record) - record.status = 'exited' - record.exitCode = e.exitCode - const now = Date.now() - record.lastActivityAt = now - record.exitedAt = now - cleanupMcpConfig(terminalId, opts.mode, record.mcpCwd) - for (const client of record.clients) { - this.flushOutputBuffer(client) - this.safeSend(client, { type: 'terminal.exit', terminalId, exitCode: e.exitCode }, { terminalId, perf: record.perf }) + const finishExit = () => { + if (this.startCodexDurableRecovery(record, { + source: 'pty_exit', + exitCode: e.exitCode, + signal: e.signal, + })) { + return + } + this.finishTerminalPtyExit(record, e) + } + if (this.needsCodexFinalDurabilityProof(record)) { + void (async () => { + await this.proveCodexBeforeFinalLoss(record, 'pty_exit') + if (record.pty !== ptyProc || record.status === 'exited') return + finishExit() + })() + return } - record.clients.clear() - record.suppressedOutputClients.clear() - record.pendingSnapshotClients.clear() - this.releaseBinding(terminalId, 'exit') - this.emit('terminal.exit', { terminalId, exitCode: e.exitCode }) - this.recordTerminalExitWithoutDurableSession(record, e.exitCode, 'pty_exit') - void this.releaseCodexSidecar(record).catch(() => undefined) - this.reapExitedTerminals() + finishExit() }) this.terminals.set(terminalId, record) + if (opts.mode === 'codex' && record.codexInputGate?.state === 'identity_pending') { + recordSessionLifecycleEvent({ + kind: 'codex_candidate_pending', + provider: 'codex', + terminalId, + generation: record.codexSidecarGeneration ?? 0, + ...(record.envContext?.tabId ? { tabId: record.envContext.tabId } : {}), + ...(record.envContext?.paneId ? { paneId: record.envContext.paneId } : {}), + ...(record.cwd ? { cwd: record.cwd } : {}), + }) + } const exactSessionId = resumeForBinding if (modeSupportsResume(opts.mode) && exactSessionId) { const bound = this.bindSession( @@ -1450,9 +1587,598 @@ export class TerminalRegistry extends EventEmitter { private registerCodexSidecarLifecycle(record: TerminalRecord): void { record.codexSidecarLifecycleUnsubscribe?.() - record.codexSidecarLifecycleUnsubscribe = record.codexSidecar?.onLifecycleLoss?.((event) => { + const sidecar = record.codexSidecar + if (!sidecar) { + record.codexSidecarLifecycleUnsubscribe = undefined + return + } + + const unsubscribers: Array<() => void> = [] + const lifecycleUnsubscribe = sidecar.onLifecycleLoss?.((event) => { this.handleCodexLifecycleLoss(record.terminalId, event) }) + if (lifecycleUnsubscribe) unsubscribers.push(lifecycleUnsubscribe) + + const candidateUnsubscribe = sidecar.onCandidate?.((candidate) => { + void this.persistCodexCandidate(record.terminalId, candidate).catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to persist Codex restore identity') + void this.failCodexFreshIdentity(record.terminalId, 'candidate_persist_failed').catch((failErr) => { + logger.error({ err: failErr, terminalId: record.terminalId }, 'Failed to mark Codex terminal non-restorable after candidate persistence failure') + }) + }) + }) + if (candidateUnsubscribe) unsubscribers.push(candidateUnsubscribe) + + const turnStartedUnsubscribe = sidecar.onTurnStarted?.((event) => { + void this.handleCodexTurnStarted(record.terminalId, event).catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to update Codex turn-start durability state') + }) + }) + if (turnStartedUnsubscribe) unsubscribers.push(turnStartedUnsubscribe) + + const turnCompletedUnsubscribe = sidecar.onTurnCompleted?.((event) => { + void this.handleCodexTurnCompleted(record.terminalId, event).catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to proof Codex rollout after turn completion') + }) + }) + if (turnCompletedUnsubscribe) unsubscribers.push(turnCompletedUnsubscribe) + + const repairUnsubscribe = sidecar.onRepairTrigger?.((event) => { + if (event.kind === 'candidate_capture_timeout') { + void this.failCodexFreshIdentity(record.terminalId, 'candidate_capture_timeout').catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to mark Codex terminal non-restorable after candidate capture timeout') + }) + return + } + this.requestCodexDurabilityProof(record.terminalId, `repair:${event.kind}`) + }) + if (repairUnsubscribe) unsubscribers.push(repairUnsubscribe) + + const fsChangedUnsubscribe = sidecar.onFsChanged?.((event) => { + this.handleCodexRolloutFsChanged(record.terminalId, event) + }) + if (fsChangedUnsubscribe) unsubscribers.push(fsChangedUnsubscribe) + + record.codexSidecarLifecycleUnsubscribe = () => { + for (const unsubscribe of unsubscribers.splice(0)) { + unsubscribe() + } + } + } + + private armCodexRolloutWatch(record: TerminalRecord): void { + const candidate = record.codexDurability?.candidate + const sidecar = record.codexSidecar + if (!candidate || !sidecar?.watchPath) return + if (record.codexRolloutWatch?.rolloutPath === candidate.rolloutPath) return + + this.unwatchCodexRollout(record, 'replace') + const watchId = `codex-rollout-${record.terminalId}-${Date.now()}` + record.codexRolloutWatch = { watchId, rolloutPath: candidate.rolloutPath } + sidecar.watchPath(candidate.rolloutPath, watchId) + .then(() => { + logger.debug({ + terminalId: record.terminalId, + watchId, + rolloutPath: candidate.rolloutPath, + }, 'Watching Codex rollout proof path') + }) + .catch((err) => { + if (record.codexRolloutWatch?.watchId === watchId) { + record.codexRolloutWatch = undefined + } + logger.warn({ + err, + terminalId: record.terminalId, + watchId, + rolloutPath: candidate.rolloutPath, + }, 'Failed to watch Codex rollout proof path') + }) + } + + private unwatchCodexRollout(record: TerminalRecord, reason: string): void { + const watch = record.codexRolloutWatch + if (!watch) return + record.codexRolloutWatch = undefined + record.codexSidecar?.unwatchPath?.(watch.watchId).catch((err) => { + logger.warn({ + err, + terminalId: record.terminalId, + watchId: watch.watchId, + rolloutPath: watch.rolloutPath, + reason, + }, 'Failed to unwatch Codex rollout proof path') + }) + } + + private handleCodexRolloutFsChanged( + terminalId: string, + event: { watchId: string; changedPaths: string[] }, + ): void { + const record = this.terminals.get(terminalId) + if (!record?.codexRolloutWatch) return + const watch = record.codexRolloutWatch + if (event.watchId !== watch.watchId) return + if (event.changedPaths.length > 0 && !event.changedPaths.includes(watch.rolloutPath)) return + this.requestCodexDurabilityProof(terminalId, 'fs_changed') + } + + private codexCandidateMatches(record: TerminalRecord, threadId: string | undefined): boolean { + const candidateThreadId = record.codexDurability?.candidate?.candidateThreadId + return !!candidateThreadId && candidateThreadId === threadId + } + + private buildCodexDurabilityRef(candidate: CodexRemoteProxyCandidate, capturedAt: number): CodexDurabilityRef | undefined { + const candidateThreadId = candidate.thread.id + const rolloutPath = typeof candidate.thread.path === 'string' ? candidate.thread.path : undefined + if (!candidateThreadId || !rolloutPath || candidate.thread.ephemeral === true || !path.isAbsolute(rolloutPath)) { + return undefined + } + return { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId, + rolloutPath, + source: candidate.source as CodexCandidateSource, + capturedAt, + }, + } + } + + private codexDurabilityRecordToRef(record: CodexDurabilityStoreRecord): CodexDurabilityRef { + return { + schemaVersion: record.schemaVersion, + state: record.state, + ...(record.candidate ? { candidate: record.candidate } : {}), + ...(record.turnCompletedAt !== undefined ? { turnCompletedAt: record.turnCompletedAt } : {}), + ...(record.lastProofFailure ? { lastProofFailure: record.lastProofFailure } : {}), + ...(record.durableThreadId ? { durableThreadId: record.durableThreadId } : {}), + ...(record.nonRestorableReason ? { nonRestorableReason: record.nonRestorableReason } : {}), + } + } + + async readCodexDurabilityForRestoreLocator(locator: CodexDurabilityRestoreLocator): Promise<CodexDurabilityRef | undefined> { + return (await this.readCodexDurabilityRecordForRestoreLocator(locator))?.durability + } + + async readCodexDurabilityRecordForRestoreLocator(locator: CodexDurabilityRestoreLocator): Promise<CodexDurabilityRestoreRecord | undefined> { + const record = await this.codexDurabilityStore.readForRestoreLocator(locator) + return record + ? { + terminalId: record.terminalId, + durability: this.codexDurabilityRecordToRef(record), + } + : undefined + } + + async deleteCodexDurabilityStoreRecord(terminalId: string, reason: string): Promise<void> { + await this.codexDurabilityStore.delete(terminalId) + logger.info({ terminalId, reason }, 'Deleted Codex durability store record') + } + + private async writeCodexDurability(record: TerminalRecord, durability: CodexDurabilityRef, updatedAt = Date.now()): Promise<CodexDurabilityRef> { + const stored = await this.codexDurabilityStore.write({ + ...durability, + terminalId: record.terminalId, + ...(record.envContext?.tabId ? { tabId: record.envContext.tabId } : {}), + ...(record.envContext?.paneId ? { paneId: record.envContext.paneId } : {}), + serverInstanceId: this.serverInstanceId, + updatedAt, + }) + const storedDurability = this.codexDurabilityRecordToRef(stored) + record.codexDurability = storedDurability + return storedDurability + } + + private async replaceCodexDurabilityStoreRecord(record: TerminalRecord, durability: CodexDurabilityRef, updatedAt = Date.now()): Promise<CodexDurabilityRef> { + await this.codexDurabilityStore.delete(record.terminalId) + return this.writeCodexDurability(record, durability, updatedAt) + } + + private async persistCodexCandidate(terminalId: string, candidate: CodexRemoteProxyCandidate): Promise<void> { + const previous = this.codexCandidatePersistenceQueues.get(terminalId) ?? Promise.resolve() + const next = previous + .catch(() => undefined) + .then(() => this.persistCodexCandidateSerial(terminalId, candidate)) + this.codexCandidatePersistenceQueues.set(terminalId, next) + void next.finally(() => { + if (this.codexCandidatePersistenceQueues.get(terminalId) === next) { + this.codexCandidatePersistenceQueues.delete(terminalId) + } + }).catch(() => undefined) + return next + } + + private async persistCodexCandidateSerial(terminalId: string, candidate: CodexRemoteProxyCandidate): Promise<void> { + const record = this.terminals.get(terminalId) + if (!record || record.status !== 'running') return + if (record.mode !== 'codex') return + if (record.resumeSessionId) return + + const capturedAt = Date.now() + const durability = this.buildCodexDurabilityRef(candidate, capturedAt) + if (!durability?.candidate) { + logger.warn({ + terminalId, + threadId: candidate.thread.id, + rolloutPath: candidate.thread.path, + ephemeral: candidate.thread.ephemeral, + source: candidate.source, + }, 'Ignoring Codex restore identity candidate without deterministic rollout path') + return + } + + if (record.codexDurability?.candidate) { + const existing = record.codexDurability.candidate + if ( + existing.candidateThreadId === durability.candidate.candidateThreadId + && existing.rolloutPath === durability.candidate.rolloutPath + ) { + record.codexSidecar?.markCandidatePersisted?.() + return + } + logger.warn({ + terminalId, + existingThreadId: existing.candidateThreadId, + candidateThreadId: durability.candidate.candidateThreadId, + }, 'Ignoring mismatched Codex restore identity candidate after one was already persisted') + return + } + + const stored = await this.codexDurabilityStore.write({ + ...durability, + terminalId: record.terminalId, + ...(record.envContext?.tabId ? { tabId: record.envContext.tabId } : {}), + ...(record.envContext?.paneId ? { paneId: record.envContext.paneId } : {}), + serverInstanceId: this.serverInstanceId, + updatedAt: capturedAt, + }) + const latest = this.terminals.get(terminalId) + if ( + latest !== record + || record.status !== 'running' + || record.resumeSessionId + || record.codexDurability?.state === 'non_restorable' + ) { + if (record.status === 'running' && record.resumeSessionId && record.codexDurability?.state === 'durable') { + await this.replaceCodexDurabilityStoreRecord(record, record.codexDurability) + } else { + await this.codexDurabilityStore.delete(terminalId) + } + logger.warn({ + terminalId, + threadId: durability.candidate.candidateThreadId, + rolloutPath: durability.candidate.rolloutPath, + }, 'Discarded late Codex restore identity candidate after terminal stopped accepting candidates') + return + } + if (record.codexDurability?.candidate) { + const existing = record.codexDurability.candidate + if ( + existing.candidateThreadId === durability.candidate.candidateThreadId + && existing.rolloutPath === durability.candidate.rolloutPath + ) { + record.codexSidecar?.markCandidatePersisted?.() + } else if (record.codexDurability) { + await this.replaceCodexDurabilityStoreRecord(record, record.codexDurability) + } + return + } + const storedDurability = this.codexDurabilityRecordToRef(stored) + record.codexDurability = storedDurability + record.codexInputGate = undefined + record.codexSidecar?.markCandidatePersisted?.() + this.armCodexRolloutWatch(record) + logger.info({ + terminalId, + candidateThreadId: storedDurability.candidate?.candidateThreadId, + rolloutPath: storedDurability.candidate?.rolloutPath, + source: storedDurability.candidate?.source, + }, 'Persisted Codex restore identity before user input') + if (storedDurability.candidate) { + recordSessionLifecycleEvent({ + kind: 'codex_candidate_captured', + provider: 'codex', + terminalId, + candidateThreadId: storedDurability.candidate.candidateThreadId, + rolloutPath: storedDurability.candidate.rolloutPath, + source: storedDurability.candidate.source, + generation: record.codexSidecarGeneration ?? 0, + }) + } + this.broadcastCodexDurability(record, storedDurability) + } + + private async failCodexFreshIdentity(terminalId: string, reason: string): Promise<void> { + const record = this.terminals.get(terminalId) + if (!record || record.mode !== 'codex' || record.status !== 'running') return + if (record.codexDurability?.candidate || record.resumeSessionId) return + + const durability: CodexDurabilityRef = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'non_restorable', + nonRestorableReason: reason, + } + try { + const stored = await this.writeCodexDurability(record, durability) + record.codexInputGate = undefined + this.broadcastCodexDurability(record, stored) + } catch (err) { + logger.error({ err, terminalId, reason }, 'Failed to persist non-restorable Codex identity state') + } + logger.warn({ terminalId, reason }, 'Closing Codex terminal before user input because restore identity was not captured') + await this.killAndWait(terminalId) + } + + private async handleCodexTurnStarted(terminalId: string, event: CodexTurnEvent): Promise<void> { + const record = this.terminals.get(terminalId) + if (!record || record.status !== 'running') return + if (!this.codexCandidateMatches(record, event.threadId)) return + if (!record.codexDurability?.candidate || record.codexDurability.state === 'durable') return + + const durability: CodexDurabilityRef = { + ...record.codexDurability, + state: 'turn_in_progress_unproven', + } + const stored = await this.writeCodexDurability(record, durability) + logger.info({ + terminalId, + candidateThreadId: stored.candidate?.candidateThreadId, + turnId: event.turnId, + }, 'Codex turn started before restore proof') + this.broadcastCodexDurability(record, stored) + } + + private async handleCodexTurnCompleted(terminalId: string, event: CodexTurnEvent): Promise<void> { + const record = this.terminals.get(terminalId) + if (!record || record.status !== 'running') return + if (!this.codexCandidateMatches(record, event.threadId)) return + if (!record.codexDurability?.candidate || record.codexDurability.state === 'durable') return + + const completedAt = Date.now() + const durability: CodexDurabilityRef = { + ...record.codexDurability, + state: 'proof_checking', + turnCompletedAt: completedAt, + } + const stored = await this.writeCodexDurability(record, durability, completedAt) + logger.info({ + terminalId, + candidateThreadId: stored.candidate?.candidateThreadId, + rolloutPath: stored.candidate?.rolloutPath, + turnId: event.turnId, + }, 'Codex turn completed; checking rollout proof') + this.broadcastCodexDurability(record, stored) + this.requestCodexDurabilityProof(terminalId, 'turn_completed') + } + + private requestCodexDurabilityProof(terminalId: string, trigger: string): void { + const record = this.terminals.get(terminalId) + if ( + !record + || !record.codexDurability?.candidate + || record.codexDurability.state === 'durable' + || record.codexDurability.state === 'non_restorable' + ) return + if (record.codexDurability.turnCompletedAt === undefined) { + logger.debug({ terminalId, trigger }, 'Skipping Codex rollout proof before turn completion') + return + } + const proofState = record.codexDurabilityProof ?? {} + record.codexDurabilityProof = proofState + if (proofState.inFlight) { + proofState.rerunRequested = true + return + } + + const run = async (): Promise<void> => { + do { + proofState.rerunRequested = false + await this.runCodexDurabilityProof(terminalId, trigger) + } while (proofState.rerunRequested) + } + proofState.inFlight = run() + .catch((err) => { + logger.error({ err, terminalId, trigger }, 'Codex rollout proof execution failed') + }) + .finally(() => { + const current = this.terminals.get(terminalId) + if (current?.codexDurabilityProof === proofState) { + proofState.inFlight = undefined + proofState.rerunRequested = false + } + }) + } + + private async runCodexDurabilityProof(terminalId: string, trigger: string): Promise<void> { + const record = this.terminals.get(terminalId) + if ( + !record + || !record.codexDurability?.candidate + || record.codexDurability.state === 'durable' + || record.codexDurability.state === 'non_restorable' + ) return + const candidate = record.codexDurability.candidate + const preProofDurability = record.codexDurability + + const checking: CodexDurabilityRef = { + ...record.codexDurability, + state: 'proof_checking', + } + const checkingStored = await this.writeCodexDurability(record, checking) + this.broadcastCodexDurability(record, checkingStored) + + const proof = await proofCodexRollout({ + rolloutPath: candidate.rolloutPath, + candidateThreadId: candidate.candidateThreadId, + }) + const checkedAt = Date.now() + if (proof.ok) { + const bound = this.bindSession(terminalId, 'codex', proof.rolloutProofId, 'association') + if (!bound.ok) { + const failed: CodexDurabilityRef = { + ...checkingStored, + state: 'non_restorable', + lastProofFailure: undefined, + nonRestorableReason: `session_binding_failed:${bound.reason}`, + } + const stored = await this.writeCodexDurability(record, failed, checkedAt) + record.codexDurabilityProof = undefined + this.unwatchCodexRollout(record, 'session_binding_failed') + logger.warn({ terminalId, proof, reason: bound.reason }, 'Codex rollout proof succeeded but session binding failed') + this.broadcastCodexDurability(record, stored) + await this.killAndWait(terminalId).catch((err) => { + logger.warn({ err, terminalId }, 'Failed to close Codex terminal after session binding failure') + }) + return + } + const durable: CodexDurabilityRef = { + ...checkingStored, + state: 'durable', + durableThreadId: proof.rolloutProofId, + lastProofFailure: undefined, + } + const stored = await this.writeCodexDurability(record, durable, checkedAt) + record.codexDurabilityProof = undefined + this.unwatchCodexRollout(record, 'durable') + logger.info({ + terminalId, + candidateThreadId: candidate.candidateThreadId, + durableThreadId: proof.rolloutProofId, + rolloutPath: candidate.rolloutPath, + trigger, + }, 'Codex rollout proof succeeded') + this.broadcastCodexDurability(record, stored) + this.broadcastCodexSessionAssociated(record, proof.rolloutProofId) + recordSessionLifecycleEvent({ + kind: 'codex_durable_session_observed', + provider: 'codex', + terminalId, + sessionId: proof.rolloutProofId, + generation: record.codexSidecarGeneration ?? 0, + source: 'sidecar', + }) + return + } + + const failed: CodexDurabilityRef = { + ...checkingStored, + state: checkingStored.turnCompletedAt !== undefined + ? 'durability_unproven_after_completion' + : preProofDurability.state, + lastProofFailure: { + reason: proof.reason, + message: proof.message, + checkedAt, + }, + } + const stored = await this.writeCodexDurability(record, failed, checkedAt) + logger.warn({ + terminalId, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + trigger, + reason: proof.reason, + message: proof.message, + }, 'Codex rollout proof failed') + this.broadcastCodexDurability(record, stored) + } + + async promoteCodexDurabilityFromCreateProof( + terminalId: string, + durableThreadId: string, + checkedAt = Date.now(), + ): Promise<BindSessionResult> { + const record = this.terminals.get(terminalId) + if (!record) return { ok: false, reason: 'terminal_missing' } + if (record.mode !== 'codex') return { ok: false, reason: 'mode_mismatch' } + if (record.status !== 'running') return { ok: false, reason: 'terminal_not_running' } + + const bound = this.bindSession(terminalId, 'codex', durableThreadId, 'association') + if (!bound.ok) return bound + const sessionId = bound.sessionId + record.resumeSessionId = sessionId + + const durability: CodexDurabilityRef = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + ...(record.codexDurability?.candidate ? { candidate: record.codexDurability.candidate } : {}), + ...(record.codexDurability?.turnCompletedAt !== undefined ? { turnCompletedAt: record.codexDurability.turnCompletedAt } : {}), + durableThreadId: sessionId, + } + const stored = await this.writeCodexDurability(record, durability, checkedAt) + record.codexDurabilityProof = undefined + this.unwatchCodexRollout(record, 'durable') + logger.info({ + terminalId, + durableThreadId: sessionId, + }, 'Codex rollout proof promoted captured restore state during terminal.create') + this.broadcastCodexDurability(record, stored) + recordSessionLifecycleEvent({ + kind: 'codex_durable_session_observed', + provider: 'codex', + terminalId, + sessionId, + generation: record.codexSidecarGeneration ?? 0, + source: 'sidecar', + }) + return { ok: true, terminalId, sessionId } + } + + private needsCodexFinalDurabilityProof(record: TerminalRecord): boolean { + return record.mode === 'codex' + && !record.resumeSessionId + && !!record.codexDurability?.candidate + && record.codexDurability.state !== 'durable' + && record.codexDurability.state !== 'non_restorable' + } + + private async proveCodexBeforeFinalLoss(record: TerminalRecord, trigger: string): Promise<void> { + if (!this.needsCodexFinalDurabilityProof(record)) return + try { + await this.runCodexDurabilityProof(record.terminalId, trigger) + } catch (err) { + logger.warn({ err, terminalId: record.terminalId, trigger }, 'Final Codex rollout proof read failed') + } + } + + private closeCodexTerminalAfterBlockedLifecycleLoss(record: TerminalRecord, event: unknown): void { + if (!record.codexRecoveryBlockedError) return + if (this.terminals.get(record.terminalId) !== record || record.status !== 'running') return + logger.error( + { err: record.codexRecoveryBlockedError, terminalId: record.terminalId, event }, + 'Closing Codex terminal because durable recovery is blocked after lifecycle loss', + ) + this.kill(record.terminalId) + } + + private broadcastCodexDurability(record: TerminalRecord, durability: CodexDurabilityRef): void { + for (const client of record.clients) { + this.safeSend(client, { + type: 'terminal.codex.durability.updated', + terminalId: record.terminalId, + durability, + }, { terminalId: record.terminalId, perf: record.perf }) + } + this.emit('terminal.codex.durability.updated', { + terminalId: record.terminalId, + durability, + }) + } + + private broadcastCodexSessionAssociated(record: TerminalRecord, sessionId: string): void { + for (const client of record.clients) { + this.safeSend(client, { + type: 'terminal.session.associated', + terminalId: record.terminalId, + sessionRef: { + provider: 'codex', + sessionId, + }, + }, { terminalId: record.terminalId, perf: record.perf }) + } } publishCodexSidecar(terminalId: string): void { @@ -1495,51 +2221,124 @@ export class TerminalRegistry extends EventEmitter { } if (!record.resumeSessionId || !record.codexRecovery) { - logger.warn( - { terminalId, event }, - 'Codex app-server reported terminal lifecycle loss without durable recovery; closing terminal', - ) - void this.killAndWait(terminalId).catch((err) => { - logger.error({ err, terminalId }, 'Failed to close terminal after Codex app-server lifecycle loss') - }) + void (async () => { + await this.proveCodexBeforeFinalLoss(record, 'lifecycle_loss') + if (record.status !== 'running') return + if (record.resumeSessionId && record.codexRecovery) { + if (!this.startCodexDurableRecovery(record, { source: 'lifecycle_loss', event })) { + this.closeCodexTerminalAfterBlockedLifecycleLoss(record, event) + } + return + } + logger.warn( + { terminalId, event }, + 'Codex app-server reported terminal lifecycle loss without durable recovery; closing terminal', + ) + await this.killAndWait(terminalId).catch((err) => { + logger.error({ err, terminalId }, 'Failed to close terminal after Codex app-server lifecycle loss') + }) + })() return } + if (!this.startCodexDurableRecovery(record, { source: 'lifecycle_loss', event })) { + this.closeCodexTerminalAfterBlockedLifecycleLoss(record, event) + } + } + + private startCodexDurableRecovery( + record: TerminalRecord, + trigger: { source: 'lifecycle_loss'; event: unknown } | { source: 'pty_exit'; exitCode: number; signal?: number }, + ): boolean { + if ( + record.mode !== 'codex' + || record.status !== 'running' + || record.codexRecoveryFinalClose + || !record.resumeSessionId + || !record.codexRecovery + ) { + return false + } + if (record.codexRecoveryBlockedError) { logger.error( - { err: record.codexRecoveryBlockedError, terminalId, event }, + { err: record.codexRecoveryBlockedError, terminalId: record.terminalId, trigger }, 'Codex durable recovery is blocked by a previous sidecar teardown failure', ) - return + return false } - if (record.codexRecoveryAttempt) return + if (record.codexRecoveryAttempt) return true logger.warn( - { terminalId, event, resumeSessionId: record.resumeSessionId }, - 'Codex app-server reported terminal lifecycle loss; starting durable recovery', + { terminalId: record.terminalId, trigger, resumeSessionId: record.resumeSessionId }, + 'Codex durable terminal lost its live worker; starting durable recovery', ) - const attempt = this.runCodexRecoveryLoop(terminalId) + const attempt = this.runCodexRecoveryLoop(record.terminalId) .catch((err) => { - logger.error({ err, terminalId }, 'Codex durable recovery loop failed') + logger.error({ err, terminalId: record.terminalId }, 'Codex durable recovery loop failed') + if (record.codexRecoveryBlockedError && this.terminals.get(record.terminalId) === record && record.status === 'running') { + if (trigger.source === 'pty_exit') { + this.finishTerminalPtyExit(record, { + exitCode: trigger.exitCode, + signal: trigger.signal, + }) + } else { + this.closeCodexTerminalAfterBlockedLifecycleLoss(record, trigger.event) + } + } }) .finally(() => { - const latest = this.terminals.get(terminalId) + const latest = this.terminals.get(record.terminalId) if (latest?.codexRecoveryAttempt === attempt) { latest.codexRecoveryAttempt = undefined } }) record.codexRecoveryAttempt = attempt + return true } private canContinueCodexRecovery(record: TerminalRecord | undefined, resumeSessionId?: string): record is TerminalRecord { - return !!record - && record.status === 'running' - && !record.codexRecoveryFinalClose - && !record.codexRecoveryBlockedError - && !!record.codexRecovery - && !!record.resumeSessionId - && (!resumeSessionId || record.resumeSessionId === resumeSessionId) + const expectedResumeSessionId = resumeSessionId ?? record?.resumeSessionId + if ( + !record + || record.status !== 'running' + || record.codexRecoveryFinalClose + || record.codexRecoveryBlockedError + || !record.codexRecovery + || !expectedResumeSessionId + ) { + return false + } + + return this.ensureCodexRecoverySessionBinding(record, expectedResumeSessionId) + } + + private ensureCodexRecoverySessionBinding(record: TerminalRecord, resumeSessionId: string): boolean { + if ( + record.status !== 'running' + || record.codexRecoveryFinalClose + || record.codexRecoveryBlockedError + || !record.codexRecovery + ) { + return false + } + + const provider = record.mode as CodingCliProviderName + const expectedKey = makeSessionKey(provider, resumeSessionId) + const owner = this.bindingAuthority.ownerForSession(provider, resumeSessionId) + if (owner && owner !== record.terminalId) return false + + const currentBinding = this.bindingAuthority.sessionForTerminal(record.terminalId) + if (currentBinding && currentBinding !== expectedKey) return false + + if (!currentBinding) { + const bound = this.bindSession(record.terminalId, provider, resumeSessionId, 'resume') + if (!bound.ok) return false + } + + record.resumeSessionId = resumeSessionId + return true } private async runCodexRecoveryLoop(terminalId: string): Promise<void> { @@ -1659,10 +2458,6 @@ export class TerminalRegistry extends EventEmitter { candidate = this.spawnCodexRecoveryPty(record, plan, resumeSessionId) await plan.sidecar.adopt({ terminalId: record.terminalId, generation }) - await plan.sidecar.waitForLoadedThread(resumeSessionId, { - ...(recovery.readinessTimeoutMs !== undefined ? { timeoutMs: recovery.readinessTimeoutMs } : {}), - ...(recovery.readinessPollMs !== undefined ? { pollMs: recovery.readinessPollMs } : {}), - }) if (candidate.exited) { throw new Error(`Codex recovery candidate PTY exited before publication with code ${candidate.exitCode ?? 'unknown'}.`) } @@ -1716,7 +2511,25 @@ export class TerminalRegistry extends EventEmitter { published = true try { + let oldPtyExited = false + let forceRetireTimer: NodeJS.Timeout | undefined + oldPty.onExit(() => { + oldPtyExited = true + if (forceRetireTimer) { + clearTimeout(forceRetireTimer) + forceRetireTimer = undefined + } + }) oldPty.kill('SIGTERM') + forceRetireTimer = setTimeout(() => { + if (oldPtyExited) return + try { + oldPty.kill('SIGKILL') + } catch { + // The old PTY may already be gone; the delayed kill is only a safety net. + } + }, 500) + forceRetireTimer.unref?.() } catch (err) { logger.warn({ err, terminalId: record.terminalId }, 'Failed to retire previous Codex recovery PTY') } @@ -1809,24 +2622,25 @@ export class TerminalRegistry extends EventEmitter { return } if (record.pty !== ptyProc || record.status === 'exited') return - this.markCodexRecoveryFinalClose(record) - record.status = 'exited' - record.exitCode = event.exitCode - const now = Date.now() - record.lastActivityAt = now - record.exitedAt = now - cleanupMcpConfig(record.terminalId, record.mode, record.mcpCwd) - for (const client of record.clients) { - this.flushOutputBuffer(client) - this.safeSend(client, { type: 'terminal.exit', terminalId: record.terminalId, exitCode: event.exitCode }, { terminalId: record.terminalId, perf: record.perf }) + const finishExit = () => { + if (this.startCodexDurableRecovery(record, { + source: 'pty_exit', + exitCode: event.exitCode, + signal: event.signal, + })) { + return + } + this.finishTerminalPtyExit(record, event) + } + if (this.needsCodexFinalDurabilityProof(record)) { + void (async () => { + await this.proveCodexBeforeFinalLoss(record, 'pty_exit') + if (record.pty !== ptyProc || record.status === 'exited') return + finishExit() + })() + return } - record.clients.clear() - record.suppressedOutputClients.clear() - record.pendingSnapshotClients.clear() - this.releaseBinding(record.terminalId, 'exit') - this.emit('terminal.exit', { terminalId: record.terminalId, exitCode: event.exitCode }) - void this.releaseCodexSidecar(record).catch(() => undefined) - this.reapExitedTerminals() + finishExit() }) } @@ -1861,9 +2675,33 @@ export class TerminalRegistry extends EventEmitter { return true } - input(terminalId: string, data: string): boolean { + input(terminalId: string, data: string): TerminalInputResult { const term = this.terminals.get(terminalId) - if (!term || term.status !== 'running') return false + if (!term) return { status: 'no_terminal' } + if ( + term.mode === 'codex' + && term.codexDurability?.state === 'non_restorable' + ) { + if (term.codexDurability.nonRestorableReason === 'candidate_capture_timeout') { + return { status: 'blocked_codex_identity_capture_timeout', terminalId } + } + return { + status: 'blocked_codex_identity_unavailable', + terminalId, + reason: term.codexDurability.nonRestorableReason, + } + } + if (term.status !== 'running') return { status: 'not_running' } + if (term.codexInputGate?.state === 'identity_pending') { + if (isCodexStartupTerminalControlInput(data)) { + term.pty.write(data) + return { status: 'written' } + } + return { status: 'blocked_codex_identity_pending', terminalId } + } + if (term.codexRecoveryAttempt) { + return { status: 'blocked_codex_recovery_pending', terminalId } + } const now = Date.now() term.lastActivityAt = now if (term.perf) { @@ -1882,6 +2720,32 @@ export class TerminalRegistry extends EventEmitter { data, at: now, } satisfies TerminalInputRawEvent) + return { status: 'written' } + } + + acknowledgeCodexCandidatePersisted(input: { + terminalId: string + candidateThreadId: string + rolloutPath: string + }): 'accepted' | 'missing_terminal' | 'mismatch' | 'no_candidate' { + const term = this.terminals.get(input.terminalId) + if (!term) return 'missing_terminal' + const candidate = term.codexDurability?.candidate + if (!candidate) return 'no_candidate' + if ( + candidate.candidateThreadId !== input.candidateThreadId + || candidate.rolloutPath !== input.rolloutPath + ) { + return 'mismatch' + } + return 'accepted' + } + + releaseCodexInputGateForTest(terminalId: string): boolean { + const term = this.terminals.get(terminalId) + if (!term) return false + term.codexInputGate = undefined + term.codexSidecar?.markCandidatePersisted?.() return true } @@ -1928,6 +2792,7 @@ export class TerminalRegistry extends EventEmitter { this.releaseBinding(terminalId, 'exit') this.emit('terminal.exit', { terminalId, exitCode: term.exitCode }) this.recordTerminalExitWithoutDurableSession(term, term.exitCode, 'user_final_close') + this.forgetCodexDurabilityStoreRecord(term, 'user_final_close') void this.releaseCodexSidecar(term).catch(() => undefined) this.reapExitedTerminals() return true @@ -1955,6 +2820,7 @@ export class TerminalRegistry extends EventEmitter { if (!term) return false this.kill(terminalId) this.terminals.delete(terminalId) + this.forgetCodexDurabilityStoreRecord(term, 'remove') return true } @@ -1962,6 +2828,7 @@ export class TerminalRegistry extends EventEmitter { const existing = this.sidecarShutdowns.get(this.sidecarShutdownKey(term.terminalId)) if (existing?.status === 'pending') return existing.promise + this.unwatchCodexRollout(term, 'sidecar_release') term.codexSidecarLifecycleUnsubscribe?.() term.codexSidecarLifecycleUnsubscribe = undefined const sidecar = term.codexSidecar @@ -2074,11 +2941,13 @@ export class TerminalRegistry extends EventEmitter { description?: string mode: TerminalMode resumeSessionId?: string + sessionRef?: { provider: CodingCliProviderName; sessionId: string } createdAt: number lastActivityAt: number status: 'running' | 'exited' hasClients: boolean cwd?: string + codexDurability?: CodexDurabilityRef }> { return Array.from(this.terminals.values()).map((t) => ({ terminalId: t.terminalId, @@ -2086,11 +2955,20 @@ export class TerminalRegistry extends EventEmitter { description: t.description, mode: t.mode, resumeSessionId: t.resumeSessionId, + sessionRef: modeSupportsResume(t.mode) + && t.resumeSessionId + && (t.mode !== 'codex' || ( + t.codexDurability?.state === 'durable' + && t.codexDurability.durableThreadId === t.resumeSessionId + )) + ? { provider: t.mode as CodingCliProviderName, sessionId: t.resumeSessionId } + : undefined, createdAt: t.createdAt, lastActivityAt: t.lastActivityAt, status: t.status, hasClients: t.clients.size > 0, cwd: t.cwd, + codexDurability: t.codexDurability, })) } @@ -2372,6 +3250,21 @@ export class TerminalRegistry extends EventEmitter { return matches[0] } + findRunningCodexTerminalByCandidate(candidateThreadId: string, rolloutPath: string): TerminalRecord | undefined { + for (const term of this.terminals.values()) { + const candidate = term.codexDurability?.candidate + if ( + term.mode === 'codex' + && term.status === 'running' + && candidate?.candidateThreadId === candidateThreadId + && candidate.rolloutPath === rolloutPath + ) { + return term + } + } + return undefined + } + repairLegacySessionOwners(mode: TerminalMode, sessionId: string, cwd?: string): RepairLegacySessionOwnersResult { if (!modeSupportsResume(mode)) { return { repaired: false, clearedTerminalIds: [] } diff --git a/server/terminal-view/service.ts b/server/terminal-view/service.ts index 969a26a15..adb7ed7f4 100644 --- a/server/terminal-view/service.ts +++ b/server/terminal-view/service.ts @@ -4,6 +4,7 @@ import { type TerminalDirectoryQuery, } from '../../shared/read-models.js' import type { SessionLocator } from '../../shared/ws-protocol.js' +import type { CodexDurabilityRef } from '../../shared/codex-durability.js' import { TerminalViewMirror } from './mirror.js' import type { TerminalDirectoryItem, @@ -25,6 +26,8 @@ type TerminalListRecord = { description?: string mode: TerminalMode resumeSessionId?: string + sessionRef?: SessionLocator + codexDurability?: CodexDurabilityRef createdAt: number lastActivityAt: number status: 'running' | 'exited' @@ -77,6 +80,28 @@ function buildRuntime(record: TerminalRecord): TerminalViewportRuntime { } } +const ANSI_ESCAPE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g +const MAX_LAST_LINE_CHARS = 500 + +function isShellPromptLine(line: string): boolean { + return /^[^\s@:]+@[^\s:]+:.+[#$%]\s*$/.test(line) +} + +function lastEmittedLine(snapshot: string): string | undefined { + const lastLine = snapshot + .replace(ANSI_ESCAPE_RE, '') + .replace(/\r/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter((line) => !isShellPromptLine(line)) + .filter(Boolean) + .at(-1) + + if (!lastLine) return undefined + if (lastLine.length <= MAX_LAST_LINE_CHARS) return lastLine + return `${lastLine.slice(0, MAX_LAST_LINE_CHARS - 3)}...` +} + function encodeCursor(payload: CursorPayload): string { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') } @@ -116,12 +141,15 @@ function buildSessionRef(mode: TerminalMode, resumeSessionId?: string): SessionL } function buildDirectoryItem(terminal: TerminalListRecord): TerminalDirectoryItem { + const sessionRef = terminal.sessionRef + ?? (terminal.mode === 'codex' ? undefined : buildSessionRef(terminal.mode, terminal.resumeSessionId)) return { terminalId: terminal.terminalId, title: terminal.title, description: terminal.description, mode: terminal.mode, - sessionRef: buildSessionRef(terminal.mode, terminal.resumeSessionId), + sessionRef, + codexDurability: terminal.codexDurability, createdAt: terminal.createdAt, lastActivityAt: terminal.lastActivityAt, status: terminal.status, @@ -179,10 +207,13 @@ export function createTerminalViewService(deps: TerminalViewServiceDeps): Termin .filter((terminal) => !config.terminalOverrides?.[terminal.terminalId]?.deleted) .map((terminal) => { const override = config.terminalOverrides?.[terminal.terminalId] + const lastLine = lastEmittedLine(deps.registry.get(terminal.terminalId)?.buffer.snapshot() || '') return { ...buildDirectoryItem(terminal), title: override?.titleOverride || terminal.title, description: override?.descriptionOverride || terminal.description, + lastLine, + last_line: lastLine, } }) .sort(compareTerminals) diff --git a/server/terminal-view/types.ts b/server/terminal-view/types.ts index d5abf8666..71462ed65 100644 --- a/server/terminal-view/types.ts +++ b/server/terminal-view/types.ts @@ -1,6 +1,7 @@ import type { TerminalMode } from '../terminal-registry.js' import type { TerminalDirectoryQuery } from '../../shared/read-models.js' import type { SessionLocator } from '../../shared/ws-protocol.js' +import type { CodexDurabilityRef } from '../../shared/codex-durability.js' export type TerminalDirectoryItem = { terminalId: string @@ -13,6 +14,7 @@ export type TerminalDirectoryItem = { status: 'running' | 'exited' hasClients: boolean cwd?: string + codexDurability?: CodexDurabilityRef } export type TerminalDirectoryPage = { diff --git a/server/updater/index.ts b/server/updater/index.ts index af964e791..d4edee871 100644 --- a/server/updater/index.ts +++ b/server/updater/index.ts @@ -2,6 +2,7 @@ import { checkForUpdate } from './version-checker.js' import { promptForUpdate } from './prompt.js' import { executeUpdate, type UpdateProgress } from './executor.js' +import { shouldSkipSourceUpdateForBranch } from '../../shared/selfhost-branch-policy.js' export type UpdateAction = 'none' | 'updated' | 'skipped' | 'error' | 'check-failed' @@ -67,6 +68,7 @@ export async function runUpdateCheck(currentVersion: string): Promise<UpdateChec * - --skip-update-check CLI flag is present * - SKIP_UPDATE_CHECK env var is 'true' * - Running via 'npm run dev' (predev lifecycle event) + * - Current branch is not main or cannot be determined * * Does NOT skip based on NODE_ENV because that may be set persistently * in dev environments even when running 'npm run serve'. @@ -74,6 +76,7 @@ export async function runUpdateCheck(currentVersion: string): Promise<UpdateChec export interface SkipCheckOptions { argv?: string[] env?: NodeJS.ProcessEnv + branch?: string } export function shouldSkipUpdateCheck(options: SkipCheckOptions = {}): boolean { @@ -83,6 +86,7 @@ export function shouldSkipUpdateCheck(options: SkipCheckOptions = {}): boolean { if (argv.includes('--skip-update-check')) return true if (env.SKIP_UPDATE_CHECK === 'true') return true if (env.npm_lifecycle_event === 'predev') return true + if (shouldSkipSourceUpdateForBranch({ branch: options.branch, env })) return true return false } diff --git a/server/ws-handler.ts b/server/ws-handler.ts index e7cee3ca8..615b53109 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -22,6 +22,7 @@ import type { OpencodeActivityRecord, SdkServerMessage, SdkSessionStatus, + TerminalTurnCompleteMessage, } from '../shared/ws-protocol.js' import type { ExtensionManager } from './extension-manager.js' import { allocateLocalhostPort } from './local-port.js' @@ -34,6 +35,10 @@ import type { TabsRegistryStore } from './tabs-registry/store.js' import type { ServerSettings } from '../shared/settings.js' import { stripAnsi } from './ai-prompts.js' import type { CodexLaunchPlan, CodexLaunchPlanner } from './coding-cli/codex-app-server/launch-planner.js' +import { + CODEX_INITIAL_LAUNCH_ATTEMPTS, + planCodexLaunchWithRetry, +} from './coding-cli/codex-app-server/launch-retry.js' import { CodexLaunchConfigError, getCodexSessionBindingReason, @@ -43,6 +48,7 @@ import { ErrorCode, ShellSchema, CodingCliProviderSchema, + SessionLocatorSchema, TerminalMetaUpdatedSchema, CodexActivityListResponseSchema, CodexActivityListSchema, @@ -50,9 +56,11 @@ import { OpencodeActivityListResponseSchema, OpencodeActivityListSchema, OpencodeActivityUpdatedSchema, + TerminalTurnCompleteSchema, HelloSchema, PingSchema, ClientDiagnosticSchema, + TerminalCodexCandidatePersistedSchema, TerminalAttachSchema, TerminalDetachSchema, TerminalInputSchema, @@ -69,11 +77,25 @@ import { SdkAttachSchema, SdkSetModelSchema, SdkSetPermissionModeSchema, + FreshAgentCreateSchema, + FreshAgentAttachSchema, + FreshAgentSendSchema, + FreshAgentInterruptSchema, + FreshAgentApprovalRespondSchema, + FreshAgentQuestionRespondSchema, + FreshAgentKillSchema, + FreshAgentForkSchema, UiScreenshotResultSchema, WS_PROTOCOL_VERSION, } from '../shared/ws-protocol.js' +import { LiveTerminalHandleSchema, type RestoreError } from '../shared/session-contract.js' +import { CODEX_DURABILITY_SCHEMA_VERSION, CodexDurabilityRefSchema } from '../shared/codex-durability.js' import { UiLayoutSyncSchema } from './agent-api/layout-schema.js' import type { LayoutStore } from './agent-api/layout-store.js' +import { + planCodexCreateRestoreDecision, + resolveCodexCreateRestoreDecision, +} from './coding-cli/codex-app-server/restore-decision.js' type WsHandlerConfig = { maxConnections: number @@ -90,6 +112,48 @@ type WsHandlerConfig = { terminalCreateRateWindowMs: number } +type FreshAgentRuntimeManagerLike = { + create: (input: any) => Promise<any> + attach: (input: any) => any + subscribe?: (locator: any, listener: (message: unknown) => void) => Promise<() => void> | (() => void) + send?: (locator: any, input: any) => Promise<void> | void + interrupt?: (locator: any) => Promise<void> | void + resolveApproval?: (locator: any, requestId: string | number, decision: Record<string, unknown>) => Promise<void> | void + answerQuestion?: (locator: any, requestId: string | number, answers: Record<string, string>) => Promise<void> | void + kill?: (locator: any) => Promise<boolean> | boolean + fork?: (locator: any, input?: Record<string, unknown>) => Promise<unknown> | unknown +} + +type FreshAgentLocator = { + sessionId: string + sessionType: string + provider: string +} + +type FreshAgentCreatedRecord = { + sessionId: string + sessionType: string + provider: string + runtimeProvider: string + sessionRef?: { provider: string; sessionId: string } +} + +type FreshAgentSubscriptionEntry = { + active: boolean + off?: () => void + pending?: Promise<void> +} + +type WsErrorLogEntry = { + code: string + messageClass: string + terminalId?: string + count: number + suppressedCount: number + firstRequestId?: string + lastRequestId?: string +} + export type WsHandlerOptions = { codingCliManager?: CodingCliSessionManager codexLaunchPlanner?: CodexLaunchPlanner @@ -104,6 +168,7 @@ export type WsHandlerOptions = { codexActivityListProvider?: () => CodexActivityRecord[] agentHistorySource?: AgentHistorySource opencodeActivityListProvider?: () => OpencodeActivityRecord[] + freshAgentRuntimeManager?: FreshAgentRuntimeManagerLike } function readWsHandlerConfig(): WsHandlerConfig { @@ -222,6 +287,29 @@ function normalizeUiSessionLocator(value: unknown): SidebarSessionLocator | unde } } +function normalizeTerminalInventoryForClient(value: unknown): unknown { + if (!value || typeof value !== 'object') return value + const terminal = value as Record<string, unknown> + const { resumeSessionId: legacyResumeSessionId, ...rest } = terminal + const explicitSessionRef = normalizeUiSessionLocator(terminal.sessionRef) + const provider = typeof terminal.mode === 'string' && modeSupportsResume(terminal.mode as TerminalMode) + ? terminal.mode + : undefined + const codexDurability = terminal.codexDurability as { state?: unknown; durableThreadId?: unknown } | undefined + const canMigrateLegacySessionRef = provider !== 'codex' || ( + codexDurability?.state === 'durable' + && codexDurability.durableThreadId === legacyResumeSessionId + ) + const migratedSessionRef = provider && isNonEmptyString(legacyResumeSessionId) && canMigrateLegacySessionRef + ? { provider, sessionId: legacyResumeSessionId } + : undefined + const sessionRef = explicitSessionRef ?? migratedSessionRef + return { + ...rest, + ...(sessionRef ? { sessionRef } : {}), + } +} + function extractSessionLocatorsFromUiContent(content: Record<string, unknown>): SidebarSessionLocator[] { const locators: SidebarSessionLocator[] = [] @@ -298,20 +386,32 @@ const TabsSyncPushRecordSchema = TabRegistryRecordBaseSchema.omit({ serverInstanceId: true, deviceId: true, deviceLabel: true, + clientInstanceId: true, }) const TabsSyncPushSchema = z.object({ type: z.literal('tabs.sync.push'), deviceId: z.string().min(1), deviceLabel: z.string().min(1), + clientInstanceId: z.string().min(1), + snapshotRevision: z.number().int().nonnegative(), records: z.array(TabsSyncPushRecordSchema), }) +type TabsSyncPushRecord = z.infer<typeof TabsSyncPushRecordSchema> const TabsSyncQuerySchema = z.object({ type: z.literal('tabs.sync.query'), requestId: z.string().min(1), deviceId: z.string().min(1), - rangeDays: z.number().int().positive().optional(), + clientInstanceId: z.string().min(1), + closedTabRetentionDays: z.number().int().min(1).max(30), +}) + +const TabsSyncClientRetireSchema = z.object({ + type: z.literal('tabs.sync.client.retire'), + deviceId: z.string().min(1), + clientInstanceId: z.string().min(1), + snapshotRevision: z.number().int().nonnegative(), }) type ClientState = { @@ -325,6 +425,8 @@ type ClientState = { sdkSessions: Set<string> sdkSubscriptions: Map<string, () => void> sdkSessionTargets: Map<string, string> + freshAgentSubscriptions: Map<string, FreshAgentSubscriptionEntry> + wsErrorLogs: Map<string, WsErrorLogEntry> interestedSessions: Set<string> sidebarOpenSessionKeys: Set<string> helloTimer?: NodeJS.Timeout @@ -384,12 +486,15 @@ export class WsHandler { private layoutStore?: LayoutStore private extensionManager?: ExtensionManager private agentHistorySource?: AgentHistorySource + private freshAgentRuntimeManager?: FreshAgentRuntimeManagerLike private terminalStreamBroker: TerminalStreamBroker private terminalCreateLocks = new Map<string, Promise<void>>() private createdTerminalByRequestId = new Map<string, string>() private sdkCreateLocks = new Map<string, Promise<void>>() private createdSdkSessionByRequestId = new Map<string, string>() private sdkSessionByCreateOwnerKey = new Map<string, string>() + private freshAgentCreateLocks = new Map<string, Promise<void>>() + private createdFreshAgentByRequestId = new Map<string, FreshAgentCreatedRecord>() private screenshotRequests = new Map<string, PendingScreenshot>() private sessionsRevision = 0 private terminalsRevision = 0 @@ -403,6 +508,15 @@ export class WsHandler { if (!payload?.terminalId) return this.forgetCreatedRequestIdsForTerminal(payload.terminalId) } + private onCodexDurabilityUpdatedBound = (payload: { terminalId?: string; durability?: unknown }) => { + if (!payload?.terminalId || payload.durability === undefined) return + this.broadcast({ + type: 'terminal.codex.durability.updated', + terminalId: payload.terminalId, + durability: payload.durability, + }) + this.broadcastTerminalsChanged() + } private sessionRepairListeners?: { scanned: (result: SessionScanResult) => void repaired: (result: SessionRepairResult) => void @@ -428,6 +542,7 @@ export class WsHandler { this.tabsRegistryStore = options.tabsRegistryStore this.layoutStore = options.layoutStore this.extensionManager = options.extensionManager + this.freshAgentRuntimeManager = options.freshAgentRuntimeManager this.agentHistorySource = options.agentHistorySource ?? (this.sdkBridge ? createAgentHistorySource({ loadSessionHistory, @@ -439,6 +554,7 @@ export class WsHandler { ? options.serverInstanceId : `srv-${randomUUID()}` this.bootId = `boot-${randomUUID()}` + this.registry.setServerInstanceId?.(this.serverInstanceId) this.terminalStreamBroker = new TerminalStreamBroker(this.registry) // Build the set of valid CLI provider/mode names from extensions @@ -467,10 +583,14 @@ export class WsHandler { shell: ShellSchema.default('system'), cwd: z.string().optional(), resumeSessionId: z.string().optional(), + sessionRef: SessionLocatorSchema.optional(), + codexDurability: CodexDurabilityRefSchema.optional(), + liveTerminal: LiveTerminalHandleSchema.optional(), restore: z.boolean().optional(), + recoveryIntent: z.literal('fresh_after_restore_unavailable').optional(), tabId: z.string().min(1).optional(), paneId: z.string().min(1).optional(), - }) + }).strict() const dynamicProviderSchema = CodingCliProviderSchema.superRefine((val, ctx) => { if (!canEnumerateCliExtensions || extensionModes.includes(val)) return @@ -491,13 +611,14 @@ export class WsHandler { maxTurns: z.number().int().positive().optional(), permissionMode: z.enum(['default', 'plan', 'acceptEdits', 'bypassPermissions']).optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - }) + }).strict() this.clientMessageSchema = z.discriminatedUnion('type', [ HelloSchema, PingSchema, ClientDiagnosticSchema, dynamicTerminalCreateSchema, + TerminalCodexCandidatePersistedSchema, TerminalAttachSchema, TerminalDetachSchema, TerminalInputSchema, @@ -507,9 +628,18 @@ export class WsHandler { OpencodeActivityListSchema, TabsSyncPushSchema, TabsSyncQuerySchema, + TabsSyncClientRetireSchema, dynamicCodingCliCreateSchema, CodingCliInputSchema, CodingCliKillSchema, + FreshAgentCreateSchema, + FreshAgentAttachSchema, + FreshAgentSendSchema, + FreshAgentInterruptSchema, + FreshAgentApprovalRespondSchema, + FreshAgentQuestionRespondSchema, + FreshAgentKillSchema, + FreshAgentForkSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -526,6 +656,7 @@ export class WsHandler { on?: (event: string, listener: (...args: any[]) => void) => void } registryWithEvents.on?.('terminal.exit', this.onTerminalExitBound) + registryWithEvents.on?.('terminal.codex.durability.updated', this.onCodexDurabilityUpdatedBound) this.wss = new WebSocketServer({ server, path: '/ws', @@ -679,16 +810,23 @@ export class WsHandler { cwd: string | undefined, resumeSessionId: string | undefined, providerSettings: { model?: string; sandbox?: string; permissionMode?: string } | undefined, + attempts = 1, ) { if (!this.codexLaunchPlanner) { throw new Error('Codex terminal launch requires the app-server launch planner.') } - return this.codexLaunchPlanner.planCreate({ + const input = { cwd, resumeSessionId, model: providerSettings?.model, sandbox: normalizeCodexSandboxSetting(providerSettings?.sandbox), approvalPolicy: providerSettings?.permissionMode, + } + return planCodexLaunchWithRetry({ + planner: this.codexLaunchPlanner, + input, + attempts, + logger: log, }) } @@ -743,6 +881,23 @@ export class WsHandler { return current } + private withFreshAgentCreateLock(key: string, task: () => Promise<void>): Promise<void> { + const previous = this.freshAgentCreateLocks.get(key) ?? Promise.resolve() + + let current: Promise<void> + current = previous + .catch(() => undefined) + .then(task) + .finally(() => { + if (this.freshAgentCreateLocks.get(key) === current) { + this.freshAgentCreateLocks.delete(key) + } + }) + + this.freshAgentCreateLocks.set(key, current) + return current + } + private async resolveSdkCreateOwnership( requestId: string, resumeSessionId?: string, @@ -823,6 +978,14 @@ export class WsHandler { } } + private clearFreshAgentCreateCachesForSession(sessionId: string): void { + for (const [requestId, cached] of this.createdFreshAgentByRequestId.entries()) { + if (cached.sessionId === sessionId) { + this.createdFreshAgentByRequestId.delete(requestId) + } + } + } + private resolveCreatedSdkSession(requestId: string): SdkSessionState | undefined { const cachedSessionId = this.createdSdkSessionByRequestId.get(requestId) if (!cachedSessionId) return undefined @@ -1053,6 +1216,8 @@ export class WsHandler { sdkSessions: new Set(), sdkSubscriptions: new Map(), sdkSessionTargets: new Map(), + freshAgentSubscriptions: new Map(), + wsErrorLogs: new Map(), interestedSessions: new Set(), sidebarOpenSessionKeys: new Set(), } @@ -1105,6 +1270,8 @@ export class WsHandler { off() } state.sdkSubscriptions.clear() + this.cancelAllFreshAgentSubscriptions(state) + this.flushWsErrorLogSummaries(state, 'connection_close') for (const [requestId, pending] of this.screenshotRequests) { if (pending.connectionId !== ws.connectionId) continue @@ -1137,6 +1304,130 @@ export class WsHandler { } } + private freshAgentKey(locator: FreshAgentLocator): string { + return `${locator.sessionType}:${locator.provider}:${locator.sessionId}` + } + + private freshAgentEventMessage(locator: FreshAgentLocator, event: unknown) { + return { + type: 'freshAgent.event', + sessionId: locator.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + event, + } + } + + private freshAgentUnavailableMessage() { + return 'Fresh Agent runtime is not enabled' + } + + private sendFreshAgentSubscriptionError(ws: LiveWebSocket, locator: FreshAgentLocator, error: unknown): void { + this.safeSend(ws, this.freshAgentEventMessage(locator, { + type: 'sdk.error', + sessionId: locator.sessionId, + code: 'FRESH_AGENT_SUBSCRIBE_FAILED', + message: errorMessage(error), + })) + } + + private logFreshAgentSubscriptionOffError(locator: FreshAgentLocator, error: unknown): void { + log.warn({ + err: error instanceof Error ? error : new Error(String(error)), + sessionId: locator.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + }, 'Fresh Agent subscription cleanup failed') + } + + private ensureFreshAgentSubscription( + ws: LiveWebSocket, + state: ClientState, + locator: FreshAgentLocator, + ): void { + const manager = this.freshAgentRuntimeManager + if (!manager?.subscribe) return + + const key = this.freshAgentKey(locator) + const existing = state.freshAgentSubscriptions.get(key) + if (existing) { + existing.active = true + return + } + + const entry: FreshAgentSubscriptionEntry = { active: true } + state.freshAgentSubscriptions.set(key, entry) + + const listener = (event: unknown) => { + if (!entry.active) return + this.safeSend(ws, this.freshAgentEventMessage(locator, event)) + } + + entry.pending = Promise.resolve() + .then(() => manager.subscribe?.(locator, listener)) + .then((off) => { + entry.pending = undefined + if (!entry.active) { + if (off) { + try { + off() + } catch (error) { + this.logFreshAgentSubscriptionOffError(locator, error) + } + } + state.freshAgentSubscriptions.delete(key) + return + } + if (off) { + entry.off = off + } + }) + .catch((error) => { + entry.pending = undefined + state.freshAgentSubscriptions.delete(key) + if (entry.active) { + this.sendFreshAgentSubscriptionError(ws, locator, error) + } + }) + } + + private cancelFreshAgentSubscription( + state: ClientState, + locator: FreshAgentLocator, + ): void { + const key = this.freshAgentKey(locator) + const entry = state.freshAgentSubscriptions.get(key) + if (!entry) return + + entry.active = false + state.freshAgentSubscriptions.delete(key) + if (entry.off) { + try { + entry.off() + } catch (error) { + this.logFreshAgentSubscriptionOffError(locator, error) + } + } + } + + private cancelAllFreshAgentSubscriptions(state: ClientState): void { + if (!state.freshAgentSubscriptions) return + for (const [key, entry] of Array.from(state.freshAgentSubscriptions.entries())) { + entry.active = false + state.freshAgentSubscriptions.delete(key) + if (entry.off) { + try { + entry.off() + } catch (error) { + log.warn({ + err: error instanceof Error ? error : new Error(String(error)), + key, + }, 'Fresh Agent subscription cleanup failed') + } + } + } + } + private closeForBackpressureIfNeeded(ws: LiveWebSocket, bufferedOverride?: number): boolean { const buffered = bufferedOverride ?? (ws.bufferedAmount as number | undefined) if (typeof buffered !== 'number' || buffered <= this.config.maxWsBufferedAmount) return false @@ -1227,10 +1518,87 @@ export class WsHandler { } } + private classifyWsError(params: { code: z.infer<typeof ErrorCode>; message: string }): string { + if (params.code === 'INVALID_TERMINAL_ID') { + return 'terminal_not_running' + } + return params.code.toLowerCase() + } + + private wsErrorLogKey(params: { + code: z.infer<typeof ErrorCode> + messageClass: string + terminalId?: string + }): string { + return `${params.code}:${params.messageClass}:${params.terminalId ?? ''}` + } + + private recordWsErrorLog( + ws: LiveWebSocket, + params: { code: z.infer<typeof ErrorCode>; message: string; requestId?: string; terminalId?: string }, + ): void { + const state = this.clientStates.get(ws) + const messageClass = this.classifyWsError(params) + const key = this.wsErrorLogKey({ + code: params.code, + messageClass, + terminalId: params.terminalId, + }) + const logs = state?.wsErrorLogs + const existing = logs?.get(key) + if (existing) { + existing.count += 1 + existing.suppressedCount += 1 + if (params.requestId) { + existing.lastRequestId = params.requestId + } + return + } + + const entry: WsErrorLogEntry = { + code: params.code, + messageClass, + terminalId: params.terminalId, + count: 1, + suppressedCount: 0, + firstRequestId: params.requestId, + lastRequestId: params.requestId, + } + logs?.set(key, entry) + log.warn({ + event: 'ws_send_error', + connectionId: ws.connectionId || 'unknown', + code: params.code, + messageClass, + ...(params.requestId ? { requestId: params.requestId } : {}), + ...(params.terminalId ? { terminalId: params.terminalId } : {}), + }, 'ws_send_error') + } + + private flushWsErrorLogSummaries(state: ClientState, reason: 'connection_close'): void { + if (!state.wsErrorLogs) return + for (const entry of state.wsErrorLogs.values()) { + if (entry.suppressedCount <= 0) continue + log.warn({ + event: 'ws_send_error_suppressed_summary', + reason, + code: entry.code, + messageClass: entry.messageClass, + ...(entry.terminalId ? { terminalId: entry.terminalId } : {}), + suppressedCount: entry.suppressedCount, + totalCount: entry.count, + ...(entry.firstRequestId ? { firstRequestId: entry.firstRequestId } : {}), + ...(entry.lastRequestId ? { lastRequestId: entry.lastRequestId } : {}), + }, 'ws_send_error_suppressed_summary') + } + state.wsErrorLogs.clear() + } + private sendError( ws: LiveWebSocket, params: { code: z.infer<typeof ErrorCode>; message: string; requestId?: string; terminalId?: string } ) { + this.recordWsErrorLog(ws, params) this.send(ws, { type: 'error', code: params.code, @@ -1627,7 +1995,7 @@ export class WsHandler { } // Send terminal inventory so the client knows what's alive - const terminals = this.registry.list() + const terminals = this.registry.list().map(normalizeTerminalInventoryForClient) const terminalMeta = this.terminalMetaListProvider?.() ?? [] this.safeSend(ws, { type: 'terminal.inventory', @@ -1665,10 +2033,46 @@ export class WsHandler { return } + if (rawBytes > this.config.maxRegularWsMessageBytes) { + const isScreenshotResult = msg?.type === 'ui.screenshot.result' + if (!isScreenshotResult) { + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: `WebSocket message exceeds ${this.config.maxRegularWsMessageBytes} bytes.`, + requestId: msg?.requestId, + }) + return + } + + const allowedScreenshotResultKeys = new Set([ + 'type', + 'requestId', + 'ok', + 'mimeType', + 'imageBase64', + 'width', + 'height', + 'changedFocus', + 'restoredFocus', + 'error', + ]) + const unknownKeys = msg && typeof msg === 'object' + ? Object.keys(msg).filter((key) => !allowedScreenshotResultKeys.has(key)) + : [] + if (unknownKeys.length > 0) { + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: `Unknown field in oversized screenshot result message: ${unknownKeys.join(', ')}`, + requestId: msg?.requestId, + }) + return + } + } + if (msg?.type === 'hello' && msg?.protocolVersion !== WS_PROTOCOL_VERSION) { this.sendError(ws, { code: 'PROTOCOL_MISMATCH', - message: `Expected protocol version ${WS_PROTOCOL_VERSION}`, + message: `Expected protocol version ${WS_PROTOCOL_VERSION}. Please reload the page.`, }) ws.close(CLOSE_CODES.PROTOCOL_MISMATCH, 'Protocol version mismatch') return @@ -1685,11 +2089,6 @@ export class WsHandler { const m = parsed.data as any messageType = m.type - if (rawBytes > this.config.maxRegularWsMessageBytes && m.type !== 'ui.screenshot.result') { - ws.close(1009, 'Message too large') - return - } - if (m.type === 'ping') { // Respond to confirm liveness. this.send(ws, { type: 'pong', timestamp: nowIso() }) @@ -1821,9 +2220,23 @@ export class WsHandler { ...(m.cwd ? { cwd: m.cwd } : {}), mode: m.mode as TerminalMode, restoreRequested: m.restore === true, - hasRequestedSessionRef: false, - ...(m.resumeSessionId ? { requestedSessionId: m.resumeSessionId } : {}), + hasRequestedSessionRef: !!m.sessionRef, + ...(m.resumeSessionId || m.sessionRef?.sessionId ? { requestedSessionId: m.resumeSessionId ?? m.sessionRef.sessionId } : {}), }) + if (m.recoveryIntent === 'fresh_after_restore_unavailable') { + recordSessionLifecycleEvent({ + kind: 'restore_unavailable_fresh_fallback', + requestId: m.requestId, + connectionId: ws.connectionId || 'unknown', + ...(m.tabId ? { tabId: m.tabId } : {}), + ...(m.paneId ? { paneId: m.paneId } : {}), + mode: m.mode as TerminalMode, + reason: m.recoveryIntent, + restoreRequested: false, + treatedAsFresh: true, + hasSessionRef: !!m.sessionRef, + }) + } const endCreateTimer = startPerfTimer( 'terminal_create', { connectionId: ws.connectionId, mode: m.mode, shell: m.shell }, @@ -1834,7 +2247,143 @@ export class WsHandler { let reused = false let error = false let rateLimited = false - let effectiveResumeSessionId = m.resumeSessionId + const requestedSessionRef = normalizeUiSessionLocator(m.sessionRef) + if ( + m.recoveryIntent === 'fresh_after_restore_unavailable' + && ( + m.restore === true + || !!m.resumeSessionId + || !!requestedSessionRef + || !!m.codexDurability + || !!m.liveTerminal + ) + ) { + error = true + this.sendError(ws, { + code: 'INVALID_CREATE_REQUEST', + message: 'Fresh recovery requests cannot include restore identity.', + requestId: m.requestId, + }) + endCreateTimer({ error, rateLimited }) + return + } + const hasReusableRequestedLiveTerminal = Boolean( + m.liveTerminal?.serverInstanceId === this.serverInstanceId + && m.liveTerminal.terminalId + && (() => { + const live = this.registry.get(m.liveTerminal.terminalId) + return live && live.status === 'running' && live.mode === m.mode + })(), + ) + let codexDurabilityForDecision = m.codexDurability + let codexDurabilityStoreRecordTerminalId: string | undefined + if (m.mode === 'codex' && m.restore === true && !requestedSessionRef && !codexDurabilityForDecision) { + try { + const restoreRecord = await this.registry.readCodexDurabilityRecordForRestoreLocator({ + ...(m.liveTerminal?.terminalId ? { terminalId: m.liveTerminal.terminalId } : {}), + ...(m.tabId ? { tabId: m.tabId } : {}), + ...(m.paneId ? { paneId: m.paneId } : {}), + ...(m.liveTerminal?.serverInstanceId ? { serverInstanceId: m.liveTerminal.serverInstanceId } : {}), + }) + codexDurabilityForDecision = restoreRecord?.durability + codexDurabilityStoreRecordTerminalId = restoreRecord?.terminalId + } catch (err) { + error = true + log.warn({ + err, + requestId: m.requestId, + connectionId: ws.connectionId, + tabId: m.tabId, + paneId: m.paneId, + terminalId: m.liveTerminal?.terminalId, + }, 'Failed to resolve Codex durability record for restore locator') + this.sendError(ws, { + code: 'RESTORE_UNAVAILABLE', + message: 'Codex restore identity is ambiguous or unavailable.', + requestId: m.requestId, + }) + endCreateTimer({ error, rateLimited }) + return + } + } + const codexRestorePlan = m.mode === 'codex' + ? planCodexCreateRestoreDecision({ + restoreRequested: m.restore === true, + legacyResumeSessionId: m.resumeSessionId, + sessionRef: requestedSessionRef, + codexDurability: codexDurabilityForDecision, + }) + : undefined + let effectiveResumeSessionId: string | undefined + if (codexRestorePlan?.kind === 'durable_session_ref_resume') { + effectiveResumeSessionId = codexRestorePlan.sessionId + } else if (m.mode !== 'codex') { + effectiveResumeSessionId = requestedSessionRef && requestedSessionRef.provider === m.mode + ? requestedSessionRef.sessionId + : m.resumeSessionId + } + if (m.mode !== 'codex' && !effectiveResumeSessionId && requestedSessionRef && requestedSessionRef.provider === m.mode) { + effectiveResumeSessionId = requestedSessionRef.sessionId + } + if (codexRestorePlan?.kind === 'reject_invalid_raw_codex_resume_request') { + error = true + this.sendError(ws, { + code: codexRestorePlan.code, + message: codexRestorePlan.message, + requestId: m.requestId, + }) + endCreateTimer({ error, rateLimited }) + return + } + const hasCodexCapturedRestoreState = codexRestorePlan?.kind === 'proof_existing_candidate_first' + if ( + m.restore === true + && modeSupportsResume(m.mode as TerminalMode) + && !hasReusableRequestedLiveTerminal + && m.mode !== 'codex' + && m.resumeSessionId + && !requestedSessionRef + ) { + error = true + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + requestId: m.requestId, + }) + endCreateTimer({ error, rateLimited }) + return + } + if ( + m.restore === true + && modeSupportsResume(m.mode as TerminalMode) + && !hasCodexCapturedRestoreState + && !hasReusableRequestedLiveTerminal + && ( + !requestedSessionRef + || requestedSessionRef.provider !== m.mode + || (m.mode === 'claude' && !isValidClaudeSessionId(requestedSessionRef.sessionId)) + ) + ) { + error = true + recordSessionLifecycleEvent({ + kind: 'restore_unavailable', + requestId: m.requestId, + connectionId: ws.connectionId || 'unknown', + ...(m.tabId ? { tabId: m.tabId } : {}), + ...(m.paneId ? { paneId: m.paneId } : {}), + mode: m.mode as TerminalMode, + reason: 'missing_canonical_session_id', + restoreRequested: true, + hasSessionRef: !!requestedSessionRef, + }) + this.sendError(ws, { + code: 'RESTORE_UNAVAILABLE', + message: 'Restore requires a canonical session reference.', + requestId: m.requestId, + }) + endCreateTimer({ error, rateLimited }) + return + } try { await this.withTerminalCreateLock( this.terminalCreateLockKey(m.mode as TerminalMode, m.requestId, effectiveResumeSessionId), @@ -1856,6 +2405,8 @@ export class WsHandler { terminalId: string createdAt: number effectiveResumeSessionId?: string + clearCodexDurability?: boolean + restoreError?: RestoreError }): Promise<boolean> => { if (opts.ws.readyState !== WebSocket.OPEN) { return false @@ -1866,7 +2417,8 @@ export class WsHandler { requestId: opts.requestId, terminalId: opts.terminalId, createdAt: opts.createdAt, - ...(opts.effectiveResumeSessionId ? { effectiveResumeSessionId: opts.effectiveResumeSessionId } : {}), + ...(opts.clearCodexDurability ? { clearCodexDurability: true } : {}), + ...(opts.restoreError ? { restoreError: opts.restoreError } : {}), }) return true } @@ -1905,6 +2457,50 @@ export class WsHandler { this.broadcastTerminalsChanged() return true } + const requestedLiveTerminal = (): TerminalRecord | undefined => { + if (m.liveTerminal?.serverInstanceId !== this.serverInstanceId) return undefined + const live = this.registry.get(m.liveTerminal.terminalId) + return live && live.status === 'running' && live.mode === m.mode ? live : undefined + } + const requestedLiveCodexCandidate = (candidate: { + candidateThreadId: string + rolloutPath: string + }): TerminalRecord | undefined => { + const live = requestedLiveTerminal() + if (!live) return undefined + const liveCandidate = live.codexDurability?.candidate + if ( + liveCandidate?.candidateThreadId !== candidate.candidateThreadId + || liveCandidate?.rolloutPath !== candidate.rolloutPath + ) { + log.warn({ + requestId: m.requestId, + connectionId: ws.connectionId, + terminalId: live.terminalId, + requestedCandidateThreadId: candidate.candidateThreadId, + liveCandidateThreadId: liveCandidate?.candidateThreadId, + }, 'Ignoring stale Codex live terminal handle with mismatched restore candidate') + return undefined + } + return live + } + const broadcastCodexSessionAssociated = (associatedTerminalId: string, sessionId: string) => { + this.broadcast({ + type: 'terminal.session.associated', + terminalId: associatedTerminalId, + sessionRef: { + provider: 'codex', + sessionId, + }, + }) + } + const broadcastCodexDurabilityUpdated = (associatedTerminalId: string, durability: unknown) => { + this.broadcast({ + type: 'terminal.codex.durability.updated', + terminalId: associatedTerminalId, + durability, + }) + } const existingId = resolveExistingRequestTerminalId(m.requestId) if (existingId) { @@ -1923,6 +2519,138 @@ export class WsHandler { this.forgetCreatedRequestId(m.requestId) } + let clearCodexDurabilityOnCreate = false + let restoreErrorOnCreate: RestoreError | undefined + let codexDurabilityStoreRecordToDeleteOnSuccessfulUse: string | undefined + const deleteCodexDurabilityStoreRecord = async (recordTerminalId: string | undefined, reason: string) => { + if (!recordTerminalId) return + await this.registry.deleteCodexDurabilityStoreRecord(recordTerminalId, reason) + if (codexDurabilityStoreRecordToDeleteOnSuccessfulUse === recordTerminalId) { + codexDurabilityStoreRecordToDeleteOnSuccessfulUse = undefined + } + } + if (m.mode === 'codex') { + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: m.restore === true, + legacyResumeSessionId: m.resumeSessionId, + sessionRef: requestedSessionRef, + codexDurability: codexDurabilityForDecision, + findLiveTerminalByCandidate: (candidate) => ( + this.registry.findRunningCodexTerminalByCandidate( + candidate.candidateThreadId, + candidate.rolloutPath, + ) ?? requestedLiveCodexCandidate(candidate) + ), + }) + + if ( + decision.kind === 'reject_invalid_raw_codex_resume_request' + || decision.kind === 'reject_missing_codex_session_ref' + ) { + error = true + this.sendError(ws, { + code: decision.code, + message: decision.message, + requestId: m.requestId, + }) + return + } + + if (decision.kind === 'durable_session_ref_resume') { + effectiveResumeSessionId = decision.sessionId + } else if (decision.kind === 'fresh_codex_launch') { + effectiveResumeSessionId = undefined + } else if (decision.kind === 'proof_succeeded_resume_durable') { + const { candidate, liveTerminal: live } = decision + if (live) { + if (codexDurabilityStoreRecordTerminalId && codexDurabilityStoreRecordTerminalId !== live.terminalId) { + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordTerminalId, + 'restore_proof_succeeded_attached_live', + ) + } + const promoted = typeof this.registry.promoteCodexDurabilityFromCreateProof === 'function' + ? await this.registry.promoteCodexDurabilityFromCreateProof(live.terminalId, decision.sessionId) + : undefined + const bound = promoted ?? this.registry.bindSession?.(live.terminalId, 'codex', decision.sessionId, 'association') + if (!bound || bound.ok) { + if (!promoted) { + live.resumeSessionId = decision.sessionId + live.codexDurability = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: decision.sessionId, + } + } + broadcastCodexDurabilityUpdated(live.terminalId, live.codexDurability ?? { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: decision.sessionId, + }) + await attachReusedTerminal(live.terminalId, live.createdAt, decision.sessionId) + broadcastCodexSessionAssociated(live.terminalId, decision.sessionId) + return + } + log.warn({ + requestId: m.requestId, + connectionId: ws.connectionId, + terminalId: live.terminalId, + sessionId: decision.sessionId, + reason: bound.reason, + }, 'Codex captured restore state proved durable but live terminal binding failed') + } + effectiveResumeSessionId = decision.sessionId + codexDurabilityStoreRecordToDeleteOnSuccessfulUse = codexDurabilityStoreRecordTerminalId + log.info({ + requestId: m.requestId, + connectionId: ws.connectionId, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + }, 'Codex captured restore state proved durable during terminal.create') + } else if (decision.kind === 'proof_failed_attach_live_candidate') { + const { candidate, proof, liveTerminal: live } = decision + log.warn({ + requestId: m.requestId, + connectionId: ws.connectionId, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + reason: proof.reason, + }, 'Codex captured restore state could not be proved during terminal.create') + if (codexDurabilityStoreRecordTerminalId && codexDurabilityStoreRecordTerminalId !== live.terminalId) { + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordTerminalId, + 'restore_proof_failed_attached_live', + ) + } + await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId) + return + } else if (decision.kind === 'proof_failed_fresh_create') { + const { candidate, proof } = decision + log.warn({ + requestId: m.requestId, + connectionId: ws.connectionId, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + reason: proof.reason, + }, 'Codex captured restore state could not be proved during terminal.create') + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordTerminalId, + 'restore_proof_failed_fresh_create', + ) + clearCodexDurabilityOnCreate = decision.clearCodexDurability + restoreErrorOnCreate = decision.restoreError + effectiveResumeSessionId = undefined + } + } + + if (!codexDurabilityForDecision?.candidate) { + const live = requestedLiveTerminal() + if (live) { + await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId) + return + } + } + if (modeSupportsResume(m.mode as TerminalMode) && effectiveResumeSessionId) { let existing = this.registry.getCanonicalRunningTerminalBySession( m.mode as TerminalMode, @@ -1939,6 +2667,10 @@ export class WsHandler { ) } if (existing) { + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordToDeleteOnSuccessfulUse, + 'restore_proof_succeeded_attached_existing', + ) await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) return } @@ -1999,6 +2731,10 @@ export class WsHandler { ) } if (existing) { + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordToDeleteOnSuccessfulUse, + 'restore_proof_succeeded_attached_existing', + ) await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) return } @@ -2057,13 +2793,15 @@ export class WsHandler { : undefined this.assertTerminalCreateAccepted() const codexPlan = m.mode === 'codex' - ? await this.planCodexLaunch(m.cwd, requestedCodexResumeSessionId, providerSettings) + ? await this.planCodexLaunch( + m.cwd, + requestedCodexResumeSessionId, + providerSettings, + CODEX_INITIAL_LAUNCH_ATTEMPTS, + ) : undefined pendingCodexPlan = codexPlan - if (codexPlan) { - effectiveResumeSessionId = codexPlan.sessionId - } this.assertTerminalCreateAccepted() const codexRecovery = codexPlan @@ -2126,17 +2864,13 @@ export class WsHandler { if (codexPlan) { await codexPlan.sidecar.adopt({ terminalId: record.terminalId, generation: 0 }) this.assertTerminalCreateAccepted() - if (requestedCodexResumeSessionId) { - await codexPlan.sidecar.waitForLoadedThread(requestedCodexResumeSessionId) - this.assertTerminalCreateAccepted() - } assertCodexCreateTerminalRunning(record) this.assertTerminalCreateAccepted() this.registry.publishCodexSidecar?.(record.terminalId) pendingCodexPlan = undefined if (effectiveResumeSessionId) { recordSessionLifecycleEvent({ - kind: 'codex_durable_session_observed', + kind: 'codex_durable_resume_started', provider: 'codex', terminalId: record.terminalId, sessionId: effectiveResumeSessionId, @@ -2145,6 +2879,10 @@ export class WsHandler { }) } } + await deleteCodexDurabilityStoreRecord( + codexDurabilityStoreRecordToDeleteOnSuccessfulUse, + 'restore_proof_succeeded_created_replacement', + ) this.assertTerminalCreateAccepted() if (m.mode !== 'shell' && typeof m.cwd === 'string' && m.cwd.trim()) { @@ -2163,6 +2901,8 @@ export class WsHandler { terminalId: record.terminalId, createdAt: record.createdAt, effectiveResumeSessionId, + clearCodexDurability: clearCodexDurabilityOnCreate, + restoreError: restoreErrorOnCreate, }) if (!sent) { // Terminal may still exist even if created delivery failed (for @@ -2171,6 +2911,9 @@ export class WsHandler { this.broadcastTerminalsChanged() return } + if (m.mode === 'codex' && effectiveResumeSessionId) { + broadcastCodexSessionAssociated(record.terminalId, effectiveResumeSessionId) + } recordSessionLifecycleEvent({ kind: 'terminal_created', @@ -2237,7 +2980,12 @@ export class WsHandler { connectionId: ws.connectionId || 'unknown', operation: 'terminal.attach', }) - this.sendError(ws, { code: 'INVALID_TERMINAL_ID', message: 'Terminal not running', terminalId: m.terminalId }) + this.sendError(ws, { + code: 'INVALID_TERMINAL_ID', + message: 'Terminal not running', + requestId: m.attachRequestId, + terminalId: m.terminalId, + }) return } if (record.status !== 'running') { @@ -2250,6 +2998,7 @@ export class WsHandler { this.sendError(ws, { code: 'INVALID_TERMINAL_ID', message: formatExitedTerminalAttachMessage(record), + requestId: m.attachRequestId, terminalId: m.terminalId, }) return @@ -2277,6 +3026,7 @@ export class WsHandler { this.sendError(ws, { code: 'INVALID_TERMINAL_ID', message: formatExitedTerminalAttachMessage(latestRecord), + requestId: m.attachRequestId, terminalId: m.terminalId, }) return @@ -2287,7 +3037,12 @@ export class WsHandler { connectionId: ws.connectionId || 'unknown', operation: 'terminal.attach', }) - this.sendError(ws, { code: 'INVALID_TERMINAL_ID', message: 'Unknown terminalId', terminalId: m.terminalId }) + this.sendError(ws, { + code: 'INVALID_TERMINAL_ID', + message: 'Unknown terminalId', + requestId: m.attachRequestId, + terminalId: m.terminalId, + }) return } if (attachResult === 'duplicate') return @@ -2315,9 +3070,62 @@ export class WsHandler { } case 'terminal.input': { - const ok = this.registry.input(m.terminalId, m.data) - if (!ok) { - if (!this.registry.get(m.terminalId)) { + const result = this.registry.input(m.terminalId, m.data) + if (result.status === 'blocked_codex_identity_pending') { + log.debug({ + terminalId: m.terminalId, + connectionId: ws.connectionId, + attemptedInputBytes: Buffer.byteLength(m.data, 'utf8'), + }, 'Codex terminal input blocked until restore identity is captured') + this.send(ws, { + type: 'terminal.input.blocked', + terminalId: m.terminalId, + reason: 'codex_identity_pending', + }) + return + } + if (result.status === 'blocked_codex_identity_capture_timeout') { + log.warn({ + terminalId: m.terminalId, + connectionId: ws.connectionId, + attemptedInputBytes: Buffer.byteLength(m.data, 'utf8'), + }, 'Codex terminal input blocked after restore identity capture timed out') + this.send(ws, { + type: 'terminal.input.blocked', + terminalId: m.terminalId, + reason: 'codex_identity_capture_timeout', + }) + return + } + if (result.status === 'blocked_codex_identity_unavailable') { + log.warn({ + terminalId: m.terminalId, + connectionId: ws.connectionId, + attemptedInputBytes: Buffer.byteLength(m.data, 'utf8'), + reason: result.reason, + }, 'Codex terminal input blocked because restore identity is unavailable') + this.send(ws, { + type: 'terminal.input.blocked', + terminalId: m.terminalId, + reason: 'codex_identity_unavailable', + }) + return + } + if (result.status === 'blocked_codex_recovery_pending') { + log.debug({ + terminalId: m.terminalId, + connectionId: ws.connectionId, + attemptedInputBytes: Buffer.byteLength(m.data, 'utf8'), + }, 'Codex terminal input blocked while durable recovery is in progress') + this.send(ws, { + type: 'terminal.input.blocked', + terminalId: m.terminalId, + reason: 'codex_recovery_pending', + }) + return + } + if (result.status !== 'written') { + if (result.status === 'no_terminal') { recordSessionLifecycleEvent({ kind: 'invalid_terminal_id_without_session_ref', terminalId: m.terminalId, @@ -2331,6 +3139,20 @@ export class WsHandler { return } + case 'terminal.codex.candidate.persisted': { + const result = this.registry.acknowledgeCodexCandidatePersisted(m) + if (result !== 'accepted') { + log.warn({ + terminalId: m.terminalId, + candidateThreadId: m.candidateThreadId, + rolloutPath: m.rolloutPath, + connectionId: ws.connectionId, + reason: result, + }, 'Received Codex candidate persisted acknowledgement that did not match server state') + } + return + } + case 'terminal.resize': { const ok = this.registry.resize(m.terminalId, m.cols, m.rows) if (!ok) { @@ -2422,36 +3244,84 @@ export class WsHandler { }) return } - for (const record of m.records) { - await this.tabsRegistryStore.upsert({ - ...record, - serverInstanceId: this.serverInstanceId, + try { + const result = await this.tabsRegistryStore.replaceClientSnapshot({ deviceId: m.deviceId, deviceLabel: m.deviceLabel, + clientInstanceId: m.clientInstanceId, + snapshotRevision: m.snapshotRevision, + records: m.records.map((record: TabsSyncPushRecord) => ({ + ...record, + serverInstanceId: this.serverInstanceId, + deviceId: m.deviceId, + deviceLabel: m.deviceLabel, + })), + }) + this.send(ws, { + type: 'tabs.sync.ack', + accepted: result.accepted, + openRecords: result.openRecords, + closedRecords: result.closedRecords, + }) + } catch (error) { + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: error instanceof Error ? error.message : String(error), + }) + } + return + } + + case 'tabs.sync.client.retire': { + if (!this.tabsRegistryStore) { + this.sendError(ws, { + code: 'INTERNAL_ERROR', + message: 'Tabs registry unavailable', + }) + return + } + try { + await this.tabsRegistryStore.retireClientSnapshot({ + deviceId: m.deviceId, + clientInstanceId: m.clientInstanceId, + snapshotRevision: m.snapshotRevision, + }) + } catch (error) { + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: error instanceof Error ? error.message : String(error), }) } - this.send(ws, { type: 'tabs.sync.ack', updated: m.records.length }) return } case 'tabs.sync.query': { if (!this.tabsRegistryStore) { + this.sendError(ws, { + code: 'INTERNAL_ERROR', + message: 'Tabs registry unavailable', + requestId: m.requestId, + }) + return + } + try { + const data = await this.tabsRegistryStore.query({ + deviceId: m.deviceId, + clientInstanceId: m.clientInstanceId, + closedTabRetentionDays: m.closedTabRetentionDays, + }) this.send(ws, { type: 'tabs.sync.snapshot', requestId: m.requestId, - data: { localOpen: [], remoteOpen: [], closed: [] }, + data, + }) + } catch (error) { + this.sendError(ws, { + code: 'INVALID_MESSAGE', + message: error instanceof Error ? error.message : String(error), + requestId: m.requestId, }) - return } - const data = await this.tabsRegistryStore.query({ - deviceId: m.deviceId, - rangeDays: m.rangeDays, - }) - this.send(ws, { - type: 'tabs.sync.snapshot', - requestId: m.requestId, - data, - }) return } @@ -2599,6 +3469,215 @@ export class WsHandler { return } + case 'freshAgent.create': { + const manager = this.freshAgentRuntimeManager + if (!manager) { + this.send(ws, { + type: 'freshAgent.create.failed', + requestId: m.requestId, + code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', + message: this.freshAgentUnavailableMessage(), + retryable: false, + }) + return + } + + await this.withFreshAgentCreateLock(m.requestId, async () => { + const cached = this.createdFreshAgentByRequestId.get(m.requestId) + if (cached) { + this.send(ws, { + type: 'freshAgent.created', + requestId: m.requestId, + ...cached, + }) + this.ensureFreshAgentSubscription(ws, state, { + sessionId: cached.sessionId, + sessionType: cached.sessionType, + provider: cached.runtimeProvider, + }) + return + } + + try { + const result = await manager.create({ + requestId: m.requestId, + sessionType: m.sessionType, + provider: m.provider, + cwd: m.cwd, + resumeSessionId: m.resumeSessionId, + sessionRef: m.sessionRef, + model: m.model, + modelSelection: m.modelSelection ?? undefined, + permissionMode: m.permissionMode, + sandbox: m.sandbox, + effort: m.effort, + plugins: m.plugins, + }) + const runtimeProvider = typeof result?.runtimeProvider === 'string' + ? result.runtimeProvider + : m.provider + if (!runtimeProvider) { + throw new Error('Fresh Agent runtime provider was not resolved') + } + const record: FreshAgentCreatedRecord = { + sessionId: result.sessionId, + sessionType: result.sessionType ?? m.sessionType, + provider: runtimeProvider, + runtimeProvider, + ...(result.sessionRef ? { sessionRef: result.sessionRef } : {}), + } + this.createdFreshAgentByRequestId.set(m.requestId, record) + this.send(ws, { + type: 'freshAgent.created', + requestId: m.requestId, + ...record, + }) + this.ensureFreshAgentSubscription(ws, state, { + sessionId: record.sessionId, + sessionType: record.sessionType, + provider: record.runtimeProvider, + }) + } catch (error) { + log.warn({ + err: error instanceof Error ? error : new Error(String(error)), + requestId: m.requestId, + sessionType: m.sessionType, + provider: m.provider, + }, 'freshAgent.create failed') + const code = typeof (error as { code?: unknown })?.code === 'string' + ? (error as { code: string }).code + : 'FRESH_AGENT_CREATE_FAILED' + this.send(ws, { + type: 'freshAgent.create.failed', + requestId: m.requestId, + code, + message: errorMessage(error), + retryable: true, + }) + } + }) + return + } + + case 'freshAgent.attach': { + const manager = this.freshAgentRuntimeManager + if (!manager) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await Promise.resolve(manager.attach(locator)) + this.ensureFreshAgentSubscription(ws, state, locator) + } catch (error) { + log.warn({ + err: error instanceof Error ? error : new Error(String(error)), + ...locator, + }, 'freshAgent.attach failed') + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.send': { + const manager = this.freshAgentRuntimeManager + if (!manager?.send) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await manager.send(locator, { text: m.text, images: m.images }) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.interrupt': { + const manager = this.freshAgentRuntimeManager + if (!manager?.interrupt) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await manager.interrupt(locator) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.approval.respond': { + const manager = this.freshAgentRuntimeManager + if (!manager?.resolveApproval) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await manager.resolveApproval(locator, m.requestId, m.decision) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.question.respond': { + const manager = this.freshAgentRuntimeManager + if (!manager?.answerQuestion) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await manager.answerQuestion(locator, m.requestId, m.answers) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.fork': { + const manager = this.freshAgentRuntimeManager + if (!manager?.fork) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + try { + await manager.fork(locator, m.input) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + + case 'freshAgent.kill': { + const manager = this.freshAgentRuntimeManager + if (!manager?.kill) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) + return + } + const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + this.cancelFreshAgentSubscription(state, locator) + try { + const success = await manager.kill(locator) + this.clearFreshAgentCreateCachesForSession(m.sessionId) + this.send(ws, { + type: 'freshAgent.killed', + sessionId: m.sessionId, + sessionType: m.sessionType, + provider: m.provider, + success, + }) + } catch (error) { + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } + return + } + case 'sdk.create': { if (!this.sdkBridge) { this.sendError(ws, { code: 'INTERNAL_ERROR', message: 'SDK bridge not enabled', requestId: m.requestId }) @@ -3222,6 +4301,20 @@ export class WsHandler { this.broadcastAuthenticated(parsed.data) } + broadcastTerminalTurnComplete(msg: Omit<TerminalTurnCompleteMessage, 'type'>): void { + const parsed = TerminalTurnCompleteSchema.safeParse({ + type: 'terminal.turn.complete', + ...msg, + }) + + if (!parsed.success) { + log.warn({ issues: parsed.error.issues }, 'Invalid terminal.turn.complete payload') + return + } + + this.broadcastAuthenticated(parsed.data) + } + /** * Prepare for hot rebind: close all client connections and set the closed * flag so the patched server.close() → this.close() is a no-op. @@ -3280,6 +4373,7 @@ export class WsHandler { off?: (event: string, listener: (...args: any[]) => void) => void } registryWithEvents.off?.('terminal.exit', this.onTerminalExitBound) + registryWithEvents.off?.('terminal.codex.durability.updated', this.onCodexDurabilityUpdatedBound) if (this.sessionRepairService && this.sessionRepairListeners) { this.sessionRepairService.off('scanned', this.sessionRepairListeners.scanned) @@ -3302,6 +4396,8 @@ export class WsHandler { this.screenshotRequests.delete(requestId) } this.createdTerminalByRequestId.clear() + this.createdFreshAgentByRequestId.clear() + this.freshAgentCreateLocks.clear() // Close all client connections for (const ws of this.connections) { diff --git a/shared/codex-durability.ts b/shared/codex-durability.ts new file mode 100644 index 000000000..02f3ef7a0 --- /dev/null +++ b/shared/codex-durability.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' + +export const CODEX_DURABILITY_SCHEMA_VERSION = 1 as const + +export const CodexDurabilityStateNameSchema = z.enum([ + 'identity_pending', + 'captured_pre_turn', + 'turn_in_progress_unproven', + 'proof_checking', + 'durable', + 'durable_resuming', + 'durability_unproven_after_completion', + 'non_restorable', +]) + +export type CodexDurabilityStateName = z.infer<typeof CodexDurabilityStateNameSchema> + +export const CodexCandidateSourceSchema = z.enum([ + 'thread_start_response', + 'thread_started_notification', + 'restored_client_state', + 'durable_resume', +]) + +export type CodexCandidateSource = z.infer<typeof CodexCandidateSourceSchema> + +export const CodexRolloutProofFailureReasonSchema = z.enum([ + 'invalid_path', + 'missing', + 'not_regular_file', + 'empty', + 'malformed_json', + 'wrong_record_type', + 'missing_payload_id', + 'mismatched_thread_id', + 'read_error', +]) + +export type CodexRolloutProofFailureReason = z.infer<typeof CodexRolloutProofFailureReasonSchema> + +export const CodexCandidateIdentitySchema = z.object({ + provider: z.literal('codex'), + candidateThreadId: z.string().min(1), + rolloutPath: z.string().min(1), + source: CodexCandidateSourceSchema, + capturedAt: z.number().int().nonnegative(), + cliVersion: z.string().min(1).optional(), +}).strict() + +export type CodexCandidateIdentity = z.infer<typeof CodexCandidateIdentitySchema> + +export const CodexProofFailureSchema = z.object({ + reason: CodexRolloutProofFailureReasonSchema, + message: z.string().min(1), + checkedAt: z.number().int().nonnegative(), +}).strict() + +export type CodexProofFailure = z.infer<typeof CodexProofFailureSchema> + +export const CodexDurabilityRefSchema = z.object({ + schemaVersion: z.literal(CODEX_DURABILITY_SCHEMA_VERSION), + state: CodexDurabilityStateNameSchema, + candidate: CodexCandidateIdentitySchema.optional(), + turnCompletedAt: z.number().int().nonnegative().optional(), + lastProofFailure: CodexProofFailureSchema.optional(), + durableThreadId: z.string().min(1).optional(), + nonRestorableReason: z.string().min(1).optional(), +}).strict() + +export type CodexDurabilityRef = z.infer<typeof CodexDurabilityRefSchema> + +export const CodexDurabilityStoreRecordSchema = CodexDurabilityRefSchema.extend({ + terminalId: z.string().min(1), + tabId: z.string().min(1).optional(), + paneId: z.string().min(1).optional(), + serverInstanceId: z.string().min(1), + updatedAt: z.number().int().nonnegative(), +}).strict() + +export type CodexDurabilityStoreRecord = z.infer<typeof CodexDurabilityStoreRecordSchema> + +export function sanitizeCodexDurabilityRef(value: unknown): CodexDurabilityRef | undefined { + const parsed = CodexDurabilityRefSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts new file mode 100644 index 000000000..c266e707b --- /dev/null +++ b/shared/fresh-agent-contract.ts @@ -0,0 +1,314 @@ +import { z } from 'zod' + +export const FreshAgentSessionTypeSchema = z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']) +export const FreshAgentRuntimeProviderSchema = z.enum(['claude', 'codex', 'opencode']) + +export const FreshAgentThreadLocatorSchema = z.object({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + threadId: z.string().min(1), +}).strict() + +export const FreshAgentRequestIdSchema = z.union([z.string().min(1), z.number().int()]) + +export const FreshAgentCapabilitiesSchema = z.object({ + send: z.boolean(), + interrupt: z.boolean(), + approvals: z.boolean(), + questions: z.boolean(), + fork: z.boolean(), + worktrees: z.boolean().optional(), + diffs: z.boolean().optional(), + childThreads: z.boolean().optional(), +}).strict() + +export const FreshAgentTokenUsageSchema = z.object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + cachedTokens: z.number().int().nonnegative().optional(), + totalTokens: z.number().int().nonnegative(), + contextTokens: z.number().int().nonnegative().optional(), + compactPercent: z.number().nonnegative().optional(), + costUsd: z.number().nonnegative().optional(), +}).strict() + +export const FreshAgentSettingsSchema = z.object({ + model: z.string().min(1).optional(), + permissionMode: z.string().min(1).optional(), + effort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional(), + plugins: z.array(z.string()).optional(), +}).strict() + +const JsonValueSchema: z.ZodType<unknown> = z.lazy(() => z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValueSchema), + z.record(z.string(), JsonValueSchema), +])) + +export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ + z.object({ + id: z.string().min(1), + kind: z.literal('text'), + text: z.string(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('thinking'), + text: z.string(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('reasoning'), + summary: z.array(z.string()), + content: z.array(z.string()), + text: z.string().optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('tool_use'), + toolUseId: z.string().min(1), + name: z.string().min(1), + input: JsonValueSchema.optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('tool_result'), + toolUseId: z.string().min(1), + content: JsonValueSchema, + isError: z.boolean(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('command'), + command: z.string(), + cwd: z.string().optional(), + status: z.enum(['running', 'completed', 'failed', 'declined']), + output: z.string().nullable().optional(), + exitCode: z.number().int().nullable().optional(), + extensions: z.record(z.string(), z.unknown()).optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('file_change'), + status: z.enum(['running', 'completed', 'failed', 'declined']), + changes: z.array(z.record(z.string(), z.unknown())), + extensions: z.record(z.string(), z.unknown()).optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('mcp_tool'), + server: z.string(), + tool: z.string(), + status: z.enum(['running', 'completed', 'failed']), + arguments: JsonValueSchema, + result: JsonValueSchema.optional(), + error: JsonValueSchema.optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('dynamic_tool'), + namespace: z.string().nullable().optional(), + tool: z.string(), + status: z.enum(['running', 'completed', 'failed']), + arguments: JsonValueSchema, + contentItems: z.array(z.unknown()).nullable().optional(), + success: z.boolean().nullable().optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('collab_agent'), + tool: z.string(), + status: z.enum(['running', 'completed', 'failed']), + senderThreadId: z.string().min(1), + receiverThreadIds: z.array(z.string().min(1)), + prompt: z.string().nullable().optional(), + model: z.string().nullable().optional(), + reasoningEffort: z.string().nullable().optional(), + agentsStates: z.record(z.string(), z.unknown()), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('web_search'), + query: z.string(), + action: z.unknown().nullable().optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('image_view'), + path: z.string(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('image_generation'), + status: z.string(), + revisedPrompt: z.string().nullable().optional(), + result: z.string(), + savedPath: z.string().optional(), + displayStatus: z.string().optional(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('review_mode'), + event: z.enum(['entered', 'exited']), + review: z.string(), + }).strict(), + z.object({ + id: z.string().min(1), + kind: z.literal('context_compaction'), + }).strict(), +]) + +export const FreshAgentTurnSchema = z.object({ + id: z.string().min(1), + turnId: z.string().min(1), + messageId: z.string().min(1).optional(), + ordinal: z.number().int().nonnegative().optional(), + source: z.enum(['durable', 'live', 'server']).optional(), + role: z.enum(['user', 'assistant', 'system', 'tool']).optional(), + timestamp: z.string().optional(), + model: z.string().optional(), + summary: z.string(), + items: z.array(FreshAgentTranscriptItemSchema), +}).strict() + +export const FreshAgentPendingApprovalSchema = z.object({ + requestId: FreshAgentRequestIdSchema, + toolName: z.string().optional(), + toolUseID: z.string().optional(), + blockedPath: z.string().optional(), + decisionReason: z.string().optional(), + input: z.record(z.string(), z.unknown()).optional(), + providerRequest: z.record(z.string(), z.unknown()).optional(), +}).strict() + +export const FreshAgentQuestionDefinitionSchema = z.object({ + question: z.string(), + header: z.string().optional(), + options: z.array(z.object({ + label: z.string(), + description: z.string(), + }).strict()).optional(), + multiSelect: z.boolean().optional(), +}).strict() + +export const FreshAgentPendingQuestionSchema = z.object({ + requestId: FreshAgentRequestIdSchema, + questions: z.array(FreshAgentQuestionDefinitionSchema), + providerRequest: z.record(z.string(), z.unknown()).optional(), +}).strict() + +export const FreshAgentWorktreeSchema = z.object({ + id: z.string().min(1), + path: z.string().min(1), + branch: z.string().optional(), +}).strict() + +export const FreshAgentDiffSummarySchema = z.object({ + id: z.string().min(1), + path: z.string().optional(), + title: z.string().optional(), + status: z.string().optional(), +}).strict() + +export const FreshAgentChildThreadSchema = z.object({ + id: z.string().min(1), + threadId: z.string().min(1), + origin: z.string(), + title: z.string().optional(), + receiverThreadIds: z.array(z.string().min(1)).optional(), +}).strict() + +export const FreshAgentExtensionsSchema = z.object({ + claude: z.record(z.string(), z.unknown()).optional(), + codex: z.record(z.string(), z.unknown()).optional(), + opencode: z.record(z.string(), z.unknown()).optional(), +}).strict() + +export const FreshAgentSnapshotSchema = FreshAgentThreadLocatorSchema.extend({ + sessionId: z.string().min(1).optional(), + revision: z.number().int().nonnegative(), + latestTurnId: z.string().nullable().optional(), + status: z.string().min(1), + summary: z.string().optional(), + capabilities: FreshAgentCapabilitiesSchema, + settings: FreshAgentSettingsSchema.optional(), + tokenUsage: FreshAgentTokenUsageSchema, + pendingApprovals: z.array(FreshAgentPendingApprovalSchema).default([]), + pendingQuestions: z.array(FreshAgentPendingQuestionSchema).default([]), + worktrees: z.array(FreshAgentWorktreeSchema).default([]), + diffs: z.array(FreshAgentDiffSummarySchema).default([]), + childThreads: z.array(FreshAgentChildThreadSchema).default([]), + turns: z.array(FreshAgentTurnSchema).default([]), + extensions: FreshAgentExtensionsSchema.default({}), +}).strict() + +export const FreshAgentTurnPageSchema = FreshAgentThreadLocatorSchema.extend({ + revision: z.number().int().nonnegative(), + nextCursor: z.string().nullable(), + backwardsCursor: z.string().nullable().optional(), + turns: z.array(FreshAgentTurnSchema), + bodies: z.record(z.string(), FreshAgentTurnSchema).optional(), +}).strict() + +export const FreshAgentTurnBodySchema = FreshAgentTurnSchema.extend({ + sessionType: FreshAgentSessionTypeSchema, + provider: FreshAgentRuntimeProviderSchema, + threadId: z.string().min(1), + revision: z.number().int().nonnegative(), +}).strict() + +export const FreshAgentActionResultSchema = FreshAgentThreadLocatorSchema.extend({ + action: z.enum([ + 'send', + 'interrupt', + 'fork', + 'review', + 'question.respond', + 'approval.respond', + ]), + revision: z.number().int().nonnegative().optional(), + result: z.record(z.string(), z.unknown()).default({}), +}).strict() + +export const FreshAgentContractErrorSchema = z.object({ + code: z.string().min(1), + message: z.string().min(1), + sessionType: FreshAgentSessionTypeSchema.optional(), + provider: FreshAgentRuntimeProviderSchema.optional(), + threadId: z.string().min(1).optional(), + details: z.unknown().optional(), +}).strict() + +export const FRESH_AGENT_CONTRACT_SCHEMA_NAMES = [ + 'FreshAgentThreadLocatorSchema', + 'FreshAgentRequestIdSchema', + 'FreshAgentCapabilitiesSchema', + 'FreshAgentTokenUsageSchema', + 'FreshAgentSettingsSchema', + 'FreshAgentTranscriptItemSchema', + 'FreshAgentTurnSchema', + 'FreshAgentPendingApprovalSchema', + 'FreshAgentPendingQuestionSchema', + 'FreshAgentWorktreeSchema', + 'FreshAgentDiffSummarySchema', + 'FreshAgentChildThreadSchema', + 'FreshAgentExtensionsSchema', + 'FreshAgentSnapshotSchema', + 'FreshAgentTurnPageSchema', + 'FreshAgentTurnBodySchema', + 'FreshAgentActionResultSchema', + 'FreshAgentContractErrorSchema', +] as const + +export type FreshAgentThreadLocator = z.infer<typeof FreshAgentThreadLocatorSchema> +export type FreshAgentRequestId = z.infer<typeof FreshAgentRequestIdSchema> +export type FreshAgentTranscriptItem = z.infer<typeof FreshAgentTranscriptItemSchema> +export type FreshAgentTurn = z.infer<typeof FreshAgentTurnSchema> +export type FreshAgentPendingApproval = z.infer<typeof FreshAgentPendingApprovalSchema> +export type FreshAgentPendingQuestion = z.infer<typeof FreshAgentPendingQuestionSchema> +export type FreshAgentSnapshot = z.infer<typeof FreshAgentSnapshotSchema> +export type FreshAgentTurnPage = z.infer<typeof FreshAgentTurnPageSchema> +export type FreshAgentTurnBody = z.infer<typeof FreshAgentTurnBodySchema> diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts new file mode 100644 index 000000000..a81d5568b --- /dev/null +++ b/shared/fresh-agent.ts @@ -0,0 +1,178 @@ +export type FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode' + +export type FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode' + +export type FreshAgentThreadIdentity = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + threadId: string +} + +export type FreshAgentSessionIdentity = Omit<FreshAgentThreadIdentity, 'threadId'> & { + sessionId: string +} + +export type FreshAgentCompatibilityShape = { + kind?: unknown + provider?: unknown + sessionType?: unknown + sessionId?: unknown + createRequestId?: unknown + status?: unknown + resumeSessionId?: unknown + sessionRef?: unknown + initialCwd?: unknown + createError?: unknown + model?: unknown + permissionMode?: unknown + effort?: unknown + plugins?: unknown + settingsDismissed?: unknown +} + +export type FreshAgentDescriptor = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + label: string + hidden?: boolean + disabled?: boolean +} + +export const FRESH_AGENT_DESCRIPTORS: readonly FreshAgentDescriptor[] = [ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + label: 'Freshclaude', + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + label: 'Freshcodex', + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + label: 'Kilroy', + hidden: true, + }, + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + label: 'Freshopencode', + disabled: true, + }, +] as const + +const FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE = new Map( + FRESH_AGENT_DESCRIPTORS.map((descriptor) => [descriptor.sessionType, descriptor]), +) + +export function isFreshAgentSessionType(value: unknown): value is FreshAgentSessionType { + return typeof value === 'string' && FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.has(value as FreshAgentSessionType) +} + +export function getFreshAgentDescriptor( + sessionType: string | undefined, +): FreshAgentDescriptor | undefined { + if (!sessionType) return undefined + return FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.get(sessionType as FreshAgentSessionType) +} + +export function resolveFreshAgentRuntimeProvider( + sessionType: string | undefined, +): FreshAgentRuntimeProvider | undefined { + return getFreshAgentDescriptor(sessionType)?.runtimeProvider +} + +export function makeFreshAgentThreadKey(identity: FreshAgentThreadIdentity): string { + return `${identity.sessionType}:${identity.provider}:${identity.threadId}` +} + +export function makeFreshAgentSessionKey(identity: FreshAgentSessionIdentity): string { + return makeFreshAgentThreadKey({ + sessionType: identity.sessionType, + provider: identity.provider, + threadId: identity.sessionId, + }) +} + +export function normalizeFreshAgentSessionType( + value: unknown, +): FreshAgentSessionType | undefined { + return isFreshAgentSessionType(value) ? value : undefined +} + +export function migrateLegacyFreshAgentContent<T extends FreshAgentCompatibilityShape>( + input: T, +): T | (Omit<T, 'kind' | 'provider'> & { + kind: 'fresh-agent' + provider: FreshAgentRuntimeProvider + sessionType: FreshAgentSessionType +}) { + if (!input || typeof input !== 'object') { + return input + } + + if (input.kind === 'fresh-agent') { + const sessionType = normalizeFreshAgentSessionType(input.sessionType) + ?? normalizeFreshAgentSessionType(input.provider) + const provider = (typeof input.provider === 'string' + && (input.provider === 'claude' || input.provider === 'codex' || input.provider === 'opencode')) + ? input.provider + : resolveFreshAgentRuntimeProvider(sessionType) + + if (!sessionType || !provider) { + return input + } + + return { + ...input, + kind: 'fresh-agent', + provider, + sessionType, + } + } + + if (input.kind !== 'agent-chat') { + return input + } + + const sessionType = normalizeFreshAgentSessionType(input.provider) + const provider = resolveFreshAgentRuntimeProvider(sessionType) + if (!sessionType || !provider) { + return input + } + + return { + ...input, + kind: 'fresh-agent', + provider, + sessionType, + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +export function migrateLegacyFreshAgentNode(node: unknown): unknown { + if (!isRecord(node)) { + return node + } + + if (node.type === 'leaf' && isRecord(node.content)) { + return { + ...node, + content: migrateLegacyFreshAgentContent(node.content), + } + } + + if (node.type === 'split' && Array.isArray(node.children)) { + return { + ...node, + children: node.children.map(migrateLegacyFreshAgentNode), + } + } + + return node +} diff --git a/shared/read-models.ts b/shared/read-models.ts index 43262124d..16005dfa6 100644 --- a/shared/read-models.ts +++ b/shared/read-models.ts @@ -56,6 +56,7 @@ export const SessionDirectoryItemSchema = z.object({ isNonInteractive: z.boolean().optional(), isRunning: z.boolean(), runningTerminalId: z.string().optional(), + liveTerminalOnly: z.boolean().optional(), }) export const SessionDirectoryPageSchema = z.object({ @@ -94,6 +95,12 @@ export const RestoreStaleRevisionResponseSchema = z.object({ currentRevision: z.number().int().nonnegative(), }) +export const FreshAgentStaleRevisionResponseSchema = z.object({ + error: z.string().min(1), + code: z.literal('STALE_THREAD_REVISION'), + currentRevision: z.number().int().nonnegative(), +}) + export const TerminalScrollbackQuerySchema = z.object({ cursor: z.string().min(1).optional(), limit: z.number().int().positive().max(200).optional(), @@ -114,5 +121,6 @@ export type TerminalDirectoryQuery = z.infer<typeof TerminalDirectoryQuerySchema export type AgentTimelinePageQuery = z.infer<typeof AgentTimelinePageQuerySchema> export type AgentTimelineTurnBodyQuery = z.infer<typeof AgentTimelineTurnBodyQuerySchema> export type RestoreStaleRevisionResponse = z.infer<typeof RestoreStaleRevisionResponseSchema> +export type FreshAgentStaleRevisionResponse = z.infer<typeof FreshAgentStaleRevisionResponseSchema> export type TerminalScrollbackQuery = z.infer<typeof TerminalScrollbackQuerySchema> export type TerminalSearchQuery = z.infer<typeof TerminalSearchQuerySchema> diff --git a/shared/selfhost-branch-policy.ts b/shared/selfhost-branch-policy.ts new file mode 100644 index 000000000..f64faea8e --- /dev/null +++ b/shared/selfhost-branch-policy.ts @@ -0,0 +1,58 @@ +export type SelfHostPolicyEnv = { + FRESHELL_SELFHOST_BRANCH?: string + SKIP_UPDATE_CHECK?: string +} + +export type SelfHostBranchResult = + | { ok: true; expectedBranch: string } + | { ok: false; code: 'mirror-branch' | 'unexpected-branch' | 'unknown-branch'; message: string } + +export function getExpectedSelfHostBranch(env: SelfHostPolicyEnv): string { + const configured = env.FRESHELL_SELFHOST_BRANCH?.trim() + if (!configured || configured === 'main') return 'dev' + return configured +} + +export function classifySelfHostBranch(input: { + branch: string | undefined + env: SelfHostPolicyEnv +}): SelfHostBranchResult { + const expectedBranch = getExpectedSelfHostBranch(input.env) + const branch = input.branch?.trim() + + if (!branch) { + return { + ok: false, + code: 'unknown-branch', + message: `Could not determine the current Git branch. Switch to '${expectedBranch}' before self-hosting.`, + } + } + + if (branch === 'main') { + return { + ok: false, + code: 'mirror-branch', + message: `Refusing to self-host from local 'main'. Local 'main' must mirror 'origin/main'. Switch to '${expectedBranch}' or set FRESHELL_SELFHOST_BRANCH.`, + } + } + + if (branch === expectedBranch) { + return { ok: true, expectedBranch } + } + + return { + ok: false, + code: 'unexpected-branch', + message: `Refusing to self-host from '${branch}'. Expected '${expectedBranch}'. Set FRESHELL_SELFHOST_BRANCH only if the user explicitly chose another integration branch.`, + } +} + +export function shouldSkipSourceUpdateForBranch(input: { + branch: string | undefined + env: SelfHostPolicyEnv +}): boolean { + if (input.env.SKIP_UPDATE_CHECK === 'true') return true + const branch = input.branch?.trim() + if (!branch) return true + return branch !== 'main' +} diff --git a/shared/session-contract.ts b/shared/session-contract.ts index 6f2d42061..28d01f3a9 100644 --- a/shared/session-contract.ts +++ b/shared/session-contract.ts @@ -143,7 +143,9 @@ export function migrateLegacyAgentChatDurableState({ const canonicalClaudeSessionId = isCanonicalClaudeSessionId(cliSessionId) ? cliSessionId - : (isCanonicalClaudeSessionId(timelineSessionId) ? timelineSessionId : undefined) + : (isCanonicalClaudeSessionId(timelineSessionId) + ? timelineSessionId + : (isCanonicalClaudeSessionId(resumeSessionId) ? resumeSessionId : undefined)) if (canonicalClaudeSessionId) { return { diff --git a/shared/settings.ts b/shared/settings.ts index 71ce9532d..5732a47f3 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -53,7 +53,7 @@ const TERMINAL_LOCAL_KEYS = [ 'osc52Clipboard', 'renderer', ] as const -const PANES_LOCAL_KEYS = ['snapThreshold', 'iconsOnTabs', 'tabAttentionStyle', 'attentionDismiss', 'sessionOpenMode'] as const +const PANES_LOCAL_KEYS = ['snapThreshold', 'iconsOnTabs', 'tabAttentionStyle', 'attentionDismiss', 'sessionOpenMode', 'multirowTabs'] as const const SIDEBAR_LOCAL_KEYS = [ 'sortMode', 'worktreeGrouping', @@ -143,6 +143,11 @@ export type ServerSettings = { externalEditor: ExternalEditor customEditorCommand?: string } + freshAgent: { + initialSetupDone?: boolean + defaultPlugins: string[] + providers: Partial<Record<string, AgentChatProviderDefaults>> + } agentChat: { initialSetupDone?: boolean defaultPlugins: string[] @@ -178,6 +183,7 @@ export type LocalSettings = { tabAttentionStyle: TabAttentionStyle attentionDismiss: AttentionDismiss sessionOpenMode: SessionOpenMode + multirowTabs: boolean } sidebar: { sortMode: SidebarSortMode @@ -190,6 +196,11 @@ export type LocalSettings = { width: number collapsed: boolean } + freshAgent: { + showThinking: boolean + showTools: boolean + showTimecodes: boolean + } agentChat: { showThinking: boolean showTools: boolean @@ -216,6 +227,7 @@ export type ResolvedSettings = { codingCli: ServerSettings['codingCli'] panes: ServerSettings['panes'] & LocalSettings['panes'] editor: ServerSettings['editor'] + freshAgent: ServerSettings['freshAgent'] & LocalSettings['freshAgent'] agentChat: ServerSettings['agentChat'] & LocalSettings['agentChat'] extensions: ServerSettings['extensions'] network: ServerSettings['network'] @@ -435,6 +447,9 @@ function normalizeExtractedLocalSeed(patch: Record<string, unknown>): LocalSetti if (SessionOpenModeSchema.safeParse(patch.panes.sessionOpenMode).success) { panes.sessionOpenMode = patch.panes.sessionOpenMode as SessionOpenMode } + if (typeof patch.panes.multirowTabs === 'boolean') { + panes.multirowTabs = patch.panes.multirowTabs as boolean + } if (Object.keys(panes).length > 0) { normalized.panes = panes } @@ -594,6 +609,11 @@ export function buildServerSettingsSchema(validCliProviders?: readonly string[]) externalEditor: ExternalEditorSchema, customEditorCommand: z.string().optional(), }).strict(), + freshAgent: z.object({ + initialSetupDone: z.boolean().optional(), + defaultPlugins: z.array(z.string()), + providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()), + }).strict(), agentChat: z.object({ initialSetupDone: z.boolean().optional(), defaultPlugins: z.array(z.string()), @@ -638,6 +658,11 @@ export function buildServerSettingsPatchSchema(validCliProviders?: readonly stri externalEditor: ExternalEditorSchema.optional(), customEditorCommand: z.string().optional(), }).strict().optional(), + freshAgent: z.object({ + initialSetupDone: z.boolean().optional(), + defaultPlugins: z.array(z.string()).optional(), + providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()).optional(), + }).strict().optional(), agentChat: z.object({ initialSetupDone: z.boolean().optional(), defaultPlugins: z.array(z.string()).optional(), @@ -690,6 +715,10 @@ export function createDefaultServerSettings(options: SettingsDefaultsOptions = { editor: { externalEditor: 'auto', }, + freshAgent: { + defaultPlugins: [], + providers: {}, + }, agentChat: { defaultPlugins: [], providers: {}, @@ -723,6 +752,7 @@ export const defaultLocalSettings: LocalSettings = { tabAttentionStyle: 'highlight', attentionDismiss: 'click', sessionOpenMode: 'tab', + multirowTabs: false, }, sidebar: { sortMode: 'activity', @@ -735,6 +765,11 @@ export const defaultLocalSettings: LocalSettings = { width: 288, collapsed: false, }, + freshAgent: { + showThinking: false, + showTools: false, + showTimecodes: false, + }, agentChat: { showThinking: false, showTools: false, @@ -899,17 +934,23 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings } } - if (isRecord(candidate.agentChat)) { - const agentChat: ServerSettingsPatch['agentChat'] = {} - if (hasOwn(candidate.agentChat, 'initialSetupDone') && typeof candidate.agentChat.initialSetupDone === 'boolean') { - agentChat.initialSetupDone = candidate.agentChat.initialSetupDone + const rawFreshAgent = isRecord(candidate.freshAgent) + ? candidate.freshAgent + : isRecord(candidate.agentChat) + ? candidate.agentChat + : null + + if (rawFreshAgent) { + const freshAgent: ServerSettingsPatch['freshAgent'] = {} + if (hasOwn(rawFreshAgent, 'initialSetupDone') && typeof rawFreshAgent.initialSetupDone === 'boolean') { + freshAgent.initialSetupDone = rawFreshAgent.initialSetupDone } - if (hasOwn(candidate.agentChat, 'defaultPlugins') && Array.isArray(candidate.agentChat.defaultPlugins)) { - agentChat.defaultPlugins = sanitizeAgentChatPluginPaths(candidate.agentChat.defaultPlugins) + if (hasOwn(rawFreshAgent, 'defaultPlugins') && Array.isArray(rawFreshAgent.defaultPlugins)) { + freshAgent.defaultPlugins = sanitizeAgentChatPluginPaths(rawFreshAgent.defaultPlugins) } - if (isRecord(candidate.agentChat.providers)) { - const providers: NonNullable<ServerSettingsPatch['agentChat']>['providers'] = {} - for (const [providerName, providerPatch] of Object.entries(candidate.agentChat.providers)) { + if (isRecord(rawFreshAgent.providers)) { + const providers: NonNullable<ServerSettingsPatch['freshAgent']>['providers'] = {} + for (const [providerName, providerPatch] of Object.entries(rawFreshAgent.providers)) { const normalizedProviderPatchInput = normalizeLegacyAgentChatProviderDefaultsInput(providerPatch) const parsed = agentChatProviderDefaultsPatchSchema.safeParse( normalizedProviderPatchInput, @@ -936,11 +977,12 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings } } if (Object.keys(providers).length > 0) { - agentChat.providers = providers + freshAgent.providers = providers } } - if (Object.keys(agentChat).length > 0) { - sanitized.agentChat = agentChat + if (Object.keys(freshAgent).length > 0) { + sanitized.freshAgent = freshAgent + sanitized.agentChat = freshAgent } } @@ -1012,9 +1054,9 @@ function normalizeLegacyAgentChatProviderDefaultsInput( export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsPatch): ServerSettings { const normalizedPatch = sanitizeServerSettingsPatch(patch) const codingCliPatch = normalizedPatch.codingCli - const agentChatPatch = normalizedPatch.agentChat - const normalizedAgentChatPatch = agentChatPatch as Partial<ServerSettings['agentChat']> | undefined - const normalizedAgentChatProvidersPatch = agentChatPatch?.providers as Partial<Record<string, AgentChatProviderDefaults>> | undefined + const freshAgentPatch = (normalizedPatch.freshAgent ?? normalizedPatch.agentChat) as + | Partial<ServerSettings['freshAgent']> + | undefined return { ...base, @@ -1045,12 +1087,19 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP providers: mergeRecordOfObjects(base.codingCli.providers, codingCliPatch?.providers), }, editor: mergeDefined(base.editor, normalizedPatch.editor), + freshAgent: { + ...mergeDefined(base.freshAgent, freshAgentPatch), + defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins') + ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins) + : base.freshAgent.defaultPlugins, + providers: mergeRecordOfObjects(base.freshAgent.providers, freshAgentPatch?.providers), + }, agentChat: { - ...mergeDefined(base.agentChat, normalizedAgentChatPatch), - defaultPlugins: hasOwn(normalizedAgentChatPatch, 'defaultPlugins') - ? sanitizeAgentChatPluginPaths(normalizedAgentChatPatch?.defaultPlugins) + ...mergeDefined(base.agentChat, freshAgentPatch), + defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins') + ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins) : base.agentChat.defaultPlugins, - providers: mergeRecordOfObjects(base.agentChat.providers, normalizedAgentChatProvidersPatch), + providers: mergeRecordOfObjects(base.agentChat.providers, freshAgentPatch?.providers), }, extensions: { disabled: hasOwn(normalizedPatch.extensions, 'disabled') @@ -1062,6 +1111,7 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP } export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings { + const freshAgentPatch = patch?.freshAgent ?? patch?.agentChat return { ...defaultLocalSettings, ...(hasOwn(patch, 'theme') ? { theme: patch?.theme ?? defaultLocalSettings.theme } : {}), @@ -1073,7 +1123,8 @@ export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings sortMode: normalizeLocalSortMode(patch?.sidebar?.sortMode), worktreeGrouping: normalizeWorktreeGrouping(patch?.sidebar?.worktreeGrouping), }, - agentChat: mergeDefined(defaultLocalSettings.agentChat, patch?.agentChat), + freshAgent: mergeDefined(defaultLocalSettings.freshAgent, freshAgentPatch), + agentChat: mergeDefined(defaultLocalSettings.agentChat, freshAgentPatch), notifications: mergeDefined(defaultLocalSettings.notifications, patch?.notifications), } } @@ -1114,12 +1165,13 @@ export function mergeLocalSettings(base: LocalSettingsPatch | undefined, patch: next.sidebar = sidebar as LocalSettingsPatch['sidebar'] } - const agentChat = mergeDefined( - (base?.agentChat || {}) as Record<string, unknown>, - patch.agentChat as Record<string, unknown> | undefined, + const freshAgent = mergeDefined( + (base?.freshAgent || base?.agentChat || {}) as Record<string, unknown>, + (patch.freshAgent || patch.agentChat) as Record<string, unknown> | undefined, ) - if (Object.keys(agentChat).length > 0) { - next.agentChat = agentChat as LocalSettingsPatch['agentChat'] + if (Object.keys(freshAgent).length > 0) { + next.freshAgent = freshAgent as LocalSettingsPatch['freshAgent'] + next.agentChat = freshAgent as LocalSettingsPatch['agentChat'] } const notifications = mergeDefined( @@ -1162,6 +1214,12 @@ export function composeResolvedSettings(server: ServerSettings, local: LocalSett ...local.panes, }, editor: { ...server.editor }, + freshAgent: { + ...server.freshAgent, + defaultPlugins: [...server.freshAgent.defaultPlugins], + providers: mergeRecordOfObjects(server.freshAgent.providers), + ...local.freshAgent, + }, agentChat: { ...server.agentChat, defaultPlugins: [...server.agentChat.defaultPlugins], @@ -1202,8 +1260,15 @@ export function extractLegacyLocalSettingsSeed( } maybeAssignNested(patch, 'sidebar', sidebarPatch) } - if (isRecord(raw.agentChat)) { - maybeAssignNested(patch, 'agentChat', pickKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS)) + const rawFreshAgentLocal = isRecord(raw.freshAgent) + ? raw.freshAgent + : isRecord(raw.agentChat) + ? raw.agentChat + : undefined + if (rawFreshAgentLocal) { + const freshAgentPatch = pickKeys(rawFreshAgentLocal, AGENT_CHAT_LOCAL_KEYS) + maybeAssignNested(patch, 'freshAgent', freshAgentPatch) + maybeAssignNested(patch, 'agentChat', freshAgentPatch) } if (isRecord(raw.notifications)) { maybeAssignNested(patch, 'notifications', pickKeys(raw.notifications, ['soundEnabled'])) @@ -1248,11 +1313,18 @@ export function stripLocalSettings( } } - if (isRecord(raw.agentChat)) { - const strippedAgentChat = omitKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS) - if (Object.keys(strippedAgentChat).length > 0) { - next.agentChat = strippedAgentChat + const rawFreshAgent = isRecord(raw.freshAgent) + ? raw.freshAgent + : isRecord(raw.agentChat) + ? raw.agentChat + : undefined + if (rawFreshAgent) { + const strippedFreshAgent = omitKeys(rawFreshAgent, AGENT_CHAT_LOCAL_KEYS) + if (Object.keys(strippedFreshAgent).length > 0) { + next.freshAgent = strippedFreshAgent + next.agentChat = strippedFreshAgent } else { + delete next.freshAgent delete next.agentChat } } diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index bff6630f3..c98177fda 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -9,7 +9,8 @@ import { z } from 'zod' import type { ClientExtensionEntry } from './extension-types.js' import type { ServerSettings } from './settings.js' -import { LiveTerminalHandleSchema, SessionRefSchema } from './session-contract.js' +import { LiveTerminalHandleSchema, SessionRefSchema, type RestoreError } from './session-contract.js' +import { CodexDurabilityRefSchema, type CodexDurabilityRef } from './codex-durability.js' // ────────────────────────────────────────────────────────────── // Shared enums and helpers @@ -22,6 +23,7 @@ export const ErrorCode = z.enum([ 'INVALID_TERMINAL_ID', 'INVALID_SESSION_ID', 'RESTORE_UNAVAILABLE', + 'INVALID_CREATE_REQUEST', 'PTY_SPAWN_FAILED', 'FILE_WATCHER_ERROR', 'INTERNAL_ERROR', @@ -32,7 +34,7 @@ export const ErrorCode = z.enum([ export type ErrorCode = z.infer<typeof ErrorCode> -export const WS_PROTOCOL_VERSION = 4 as const +export const WS_PROTOCOL_VERSION = 5 as const export const ShellSchema = z.enum(['system', 'cmd', 'powershell', 'wsl']) @@ -127,6 +129,14 @@ export const OpencodeActivityUpdatedSchema = z.object({ remove: z.array(z.string().min(1)), }) +export const TerminalTurnCompleteSchema = z.object({ + type: z.literal('terminal.turn.complete'), + terminalId: z.string().min(1), + provider: z.literal('opencode'), + sessionId: z.string().min(1), + at: z.number().int().nonnegative(), +}) + // ────────────────────────────────────────────────────────────── // SDK content block schemas (from Claude Code NDJSON) // ────────────────────────────────────────────────────────────── @@ -219,12 +229,22 @@ export const TerminalCreateSchema = z.object({ shell: ShellSchema.default('system'), cwd: z.string().optional(), sessionRef: SessionLocatorSchema.optional(), + codexDurability: CodexDurabilityRefSchema.optional(), liveTerminal: LiveTerminalHandleSchema.optional(), restore: z.boolean().optional(), + recoveryIntent: z.literal('fresh_after_restore_unavailable').optional(), tabId: z.string().min(1).optional(), paneId: z.string().min(1).optional(), }).strict() +export const TerminalCodexCandidatePersistedSchema = z.object({ + type: z.literal('terminal.codex.candidate.persisted'), + terminalId: z.string().min(1), + candidateThreadId: z.string().min(1), + rolloutPath: z.string().min(1), + capturedAt: z.number().int().nonnegative(), +}).strict() + export const TerminalAttachIntentSchema = z.enum([ 'viewport_hydrate', 'keepalive_delta', @@ -300,7 +320,7 @@ export const UiScreenshotResultSchema = z.object({ changedFocus: z.boolean().optional(), restoredFocus: z.boolean().optional(), error: z.string().optional(), -}) +}).strict() // Coding CLI session schemas export const CodingCliCreateSchema = z.object({ @@ -395,7 +415,91 @@ export const SdkQuestionRespondSchema = z.object({ answers: z.record(z.string(), z.string()), }) +export const FreshAgentCreateSchema = z.object({ + type: z.literal('freshAgent.create'), + requestId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']).optional(), + cwd: z.string().optional(), + resumeSessionId: z.string().optional(), + model: z.string().optional(), + permissionMode: z.string().optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + sessionRef: z.object({ provider: z.string().min(1), sessionId: z.string().min(1) }).optional(), + modelSelection: z.object({ kind: z.string().min(1), modelId: z.string().min(1) }).optional().or(z.null()), + effort: z.string().trim().min(1).optional(), + plugins: z.array(z.string()).optional(), +}) + +export const FreshAgentAttachSchema = z.object({ + type: z.literal('freshAgent.attach'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + resumeSessionId: z.string().optional(), +}) + +export const FreshAgentSendSchema = z.object({ + type: z.literal('freshAgent.send'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + text: z.string().min(1), + images: z.array(z.object({ + mediaType: z.string(), + data: z.string(), + })).optional(), +}) + +export const FreshAgentInterruptSchema = z.object({ + type: z.literal('freshAgent.interrupt'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), +}) + +export const FreshAgentApprovalRespondSchema = z.object({ + type: z.literal('freshAgent.approval.respond'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + requestId: z.union([z.string().min(1), z.number().int()]), + decision: z.record(z.string(), z.unknown()), +}) + +export const FreshAgentQuestionRespondSchema = z.object({ + type: z.literal('freshAgent.question.respond'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + requestId: z.union([z.string().min(1), z.number().int()]), + answers: z.record(z.string(), z.string()), +}) + +export const FreshAgentKillSchema = z.object({ + type: z.literal('freshAgent.kill'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), +}) + +export const FreshAgentForkSchema = z.object({ + type: z.literal('freshAgent.fork'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + input: z.record(z.string(), z.unknown()).optional(), +}) + export const BrowserSdkMessageSchema = z.discriminatedUnion('type', [ + FreshAgentCreateSchema, + FreshAgentAttachSchema, + FreshAgentSendSchema, + FreshAgentInterruptSchema, + FreshAgentApprovalRespondSchema, + FreshAgentQuestionRespondSchema, + FreshAgentKillSchema, + FreshAgentForkSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -416,6 +520,7 @@ export const ClientMessageSchema = z.discriminatedUnion('type', [ PingSchema, ClientDiagnosticSchema, TerminalCreateSchema, + TerminalCodexCandidatePersistedSchema, TerminalAttachSchema, TerminalDetachSchema, TerminalInputSchema, @@ -428,6 +533,14 @@ export const ClientMessageSchema = z.discriminatedUnion('type', [ CodingCliCreateSchema, CodingCliInputSchema, CodingCliKillSchema, + FreshAgentCreateSchema, + FreshAgentAttachSchema, + FreshAgentSendSchema, + FreshAgentInterruptSchema, + FreshAgentApprovalRespondSchema, + FreshAgentQuestionRespondSchema, + FreshAgentKillSchema, + FreshAgentForkSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -475,6 +588,8 @@ export type TerminalCreatedMessage = { requestId: string terminalId: string createdAt: number + clearCodexDurability?: boolean + restoreError?: RestoreError } export type TerminalAttachReadyMessage = { @@ -535,6 +650,18 @@ export type TerminalSessionAssociatedMessage = { sessionRef: SessionLocator } +export type TerminalCodexDurabilityUpdatedMessage = { + type: 'terminal.codex.durability.updated' + terminalId: string + durability: CodexDurabilityRef +} + +export type TerminalInputBlockedMessage = { + type: 'terminal.input.blocked' + terminalId: string + reason: 'codex_identity_pending' | 'codex_identity_capture_timeout' | 'codex_identity_unavailable' | 'codex_recovery_pending' +} + export type TerminalsChangedMessage = { type: 'terminals.changed' revision: number @@ -550,6 +677,8 @@ export type OpencodeActivityListResponseMessage = z.infer<typeof OpencodeActivit export type OpencodeActivityUpdatedMessage = z.infer<typeof OpencodeActivityUpdatedSchema> +export type TerminalTurnCompleteMessage = z.infer<typeof TerminalTurnCompleteSchema> + // -- Sessions -- export type SessionsChangedMessage = { @@ -589,16 +718,31 @@ export type ConfigFallbackMessage = { export type TabsSyncAckMessage = { type: 'tabs.sync.ack' - updated: number + accepted: boolean + openRecords: number + closedRecords: number +} + +export type TabsSyncSnapshotOpenRecord = Record<string, unknown> & { + deviceId: string + deviceLabel: string + clientInstanceId: string +} + +export type TabsSyncSnapshotClosedRecord = Record<string, unknown> & { + deviceId: string + deviceLabel: string } export type TabsSyncSnapshotMessage = { type: 'tabs.sync.snapshot' requestId: string data: { - localOpen: unknown[] - remoteOpen: unknown[] - closed: unknown[] + localOpen: TabsSyncSnapshotOpenRecord[] + sameDeviceOpen: TabsSyncSnapshotOpenRecord[] + remoteOpen: TabsSyncSnapshotOpenRecord[] + closed: TabsSyncSnapshotClosedRecord[] + devices: Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> } } @@ -677,6 +821,12 @@ export type SdkRestoreFailureCode = | 'RESTORE_DIVERGED' | 'RESTORE_STALE_REVISION' +export type FreshAgentServerMessage = + | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } + | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean } + | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown } + | { type: 'freshAgent.killed'; sessionId: string; sessionType: string; provider: string; success: boolean } + export type SdkServerMessage = | { type: 'sdk.created'; requestId: string; sessionId: string } | { @@ -752,6 +902,7 @@ export type TerminalInventoryMessage = { status: 'running' | 'exited' runtimeStatus?: 'running' | 'recovering' cwd?: string + codexDurability?: CodexDurabilityRef }> terminalMeta: TerminalMetaRecord[] } @@ -771,6 +922,8 @@ export type ServerMessage = | TerminalOutputGapMessage | TerminalTitleUpdatedMessage | TerminalSessionAssociatedMessage + | TerminalCodexDurabilityUpdatedMessage + | TerminalInputBlockedMessage | TerminalsChangedMessage | TerminalMetaUpdatedMessage | TerminalInventoryMessage @@ -778,6 +931,7 @@ export type ServerMessage = | CodexActivityUpdatedMessage | OpencodeActivityListResponseMessage | OpencodeActivityUpdatedMessage + | TerminalTurnCompleteMessage | SessionsChangedMessage | SettingsUpdatedMessage | UiCommandMessage @@ -792,6 +946,7 @@ export type ServerMessage = | CodingCliExitMessage | CodingCliStderrMessage | CodingCliKilledMessage + | FreshAgentServerMessage | SdkServerMessage | ExtensionRegistryMessage | ExtensionServerStartingMessage diff --git a/src/App.tsx b/src/App.tsx index 379406fc3..698adbe23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { setStatus, setError, setErrorCode, setServerInstanceId, setBootId, setS import { setLocalSettings, setServerSettings } from '@/store/settingsSlice' import { markWsSnapshotReceived, + patchSessionRunningStateFromTerminalMeta, resetWsSnapshotReceived, } from '@/store/sessionsSlice' import { addTab, closeTab, reopenClosedTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' @@ -13,6 +14,8 @@ import { loadInitialSessionsWindow, queueActiveSessionWindowRefresh, } from '@/store/sessionsThunks' +import { fetchTerminalDirectoryWindow } from '@/store/terminalDirectoryThunks' +import { createTerminalInvalidationHandler } from '@/lib/terminal-invalidation-handler' import { getShareAction, ensureShareUrlToken, isRemoteAccessEnabledStatus } from '@/lib/share-utils' import { getWsClient } from '@/lib/ws-client' import { collectSessionLocatorsFromTabs, getSessionsForHello } from '@/lib/session-utils' @@ -56,14 +59,18 @@ import { updateSettingsLocal } from '@/store/settingsSlice' import { setTerminalMetaSnapshot, upsertTerminalMeta, removeTerminalMeta } from '@/store/terminalMetaSlice' import { clearDeadTerminals } from '@/store/panesSlice' -import { addTerminalRestoreRequestId } from '@/lib/terminal-restore' +import { addTerminalFreshRecoveryRequestId, addTerminalRestoreRequestId } from '@/lib/terminal-restore' import { setCodexActivitySnapshot, upsertCodexActivity, removeCodexActivity, resetCodexActivity } from '@/store/codexActivitySlice' import { setOpencodeActivitySnapshot, upsertOpencodeActivity, removeOpencodeActivity, resetOpencodeActivity } from '@/store/opencodeActivitySlice' +import { recordTurnComplete } from '@/store/turnCompletionSlice' +import { selectTabPaneByTerminalId } from '@/store/selectors/paneTerminalSelectors' import { setRegistry, updateServerStatus } from '@/store/extensionsSlice' import { handleSdkMessage } from '@/lib/sdk-message-handler' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' import { createLogger } from '@/lib/client-logger' import type { LocalSettingsPatch, ServerSettings } from '@shared/settings' import { z } from 'zod' +import { withChunkErrorRecovery } from '@/lib/import-retry' const log = createLogger('App') @@ -89,9 +96,9 @@ function ShareQrCode({ url }: { url: string }) { return <img src={svgUrl} alt="QR code for access URL" className="w-48 h-48" /> } -const HistoryView = lazy(() => import('@/components/HistoryView')) -const SettingsView = lazy(() => import('@/components/SettingsView')) -const ExtensionsView = lazy(() => import('@/components/ExtensionsView')) +const HistoryView = lazy(() => withChunkErrorRecovery(import('@/components/HistoryView'))) +const SettingsView = lazy(() => withChunkErrorRecovery(import('@/components/SettingsView'))) +const ExtensionsView = lazy(() => withChunkErrorRecovery(import('@/components/ExtensionsView'))) const SIDEBAR_MIN_WIDTH = 200 const SIDEBAR_MAX_WIDTH = 500 @@ -195,6 +202,7 @@ export default function App() { () => { (ws as any).ws?.close() }, // sendWsMessage: send a raw WS message for test cleanup (e.g., terminal.kill) (msg: unknown) => { ws.send(msg) }, + (msg) => { ws.receiveMessageForTest?.(msg) }, () => perfAuditBridgeRef.current?.snapshot() ?? null, ) ws.setOutboundMessageObserver?.((msg) => { @@ -723,6 +731,15 @@ export default function App() { } } + const terminalInvalidationHandler = createTerminalInvalidationHandler({ + dispatch: (action) => appStore.dispatch(action as any), + upsertTerminalMeta, + removeTerminalMeta, + patchSessionRunningStateFromTerminalMeta, + queueActiveSessionWindowRefresh: () => queueActiveSessionWindowRefresh() as any, + fetchTerminalDirectoryWindow: (payload) => fetchTerminalDirectoryWindow(payload) as any, + }) + const unsubscribe = ws.onMessage((msg) => { if (!msg?.type) return if (msg.type === 'ready') { @@ -780,36 +797,52 @@ export default function App() { send: (payload) => ws.send(payload), }) } - if (msg.type === 'terminal.meta.updated') { - const upsert = Array.isArray(msg.upsert) ? msg.upsert : [] - if (upsert.length > 0) { - dispatch(upsertTerminalMeta(upsert)) - } - - const remove = Array.isArray(msg.remove) ? msg.remove : [] - for (const terminalId of remove) { - dispatch(removeTerminalMeta(terminalId)) - } + if (terminalInvalidationHandler.handle(msg as any)) { + return } if (msg.type === 'terminal.inventory') { const terminals = Array.isArray(msg.terminals) ? msg.terminals : [] const terminalMeta = Array.isArray(msg.terminalMeta) ? msg.terminalMeta : [] + const terminalMetaRequestedAt = Date.now() + const previousTerminalMeta = appStore.getState().terminalMeta?.byTerminalId ?? {} + const incomingTerminalMetaIds = new Set( + terminalMeta + .map((record: any) => record?.terminalId) + .filter((terminalId: unknown): terminalId is string => typeof terminalId === 'string'), + ) + const removedTerminalMetaIds = Object.entries(previousTerminalMeta) + .filter(([terminalId, record]) => ( + !incomingTerminalMetaIds.has(terminalId) + && !(typeof record?.updatedAt === 'number' && record.updatedAt > terminalMetaRequestedAt) + )) + .map(([terminalId]) => terminalId) const liveIds = terminals .filter((t: any) => t.status === 'running') .map((t: any) => t.terminalId as string) dispatch(setLiveTerminalIds(liveIds)) dispatch(setServerRestarted(false)) dispatch(clearDeadTerminals({ liveTerminalIds: liveIds })) - // Register new createRequestIds with the restore set so the - // subsequent terminal.create messages include restore: true - // and bypass the server's rate limiter. + // Register regenerated createRequestIds with the correct explicit + // recovery path after stale terminal handles are cleared. const layouts = appStore.getState().panes.layouts - for (const layout of Object.values(layouts)) { + const fallbackAttempts = appStore.getState().panes.restoreFallbackAttemptsByPane || {} + for (const [tabId, layout] of Object.entries(layouts)) { ;(function walk(node: any) { if (!node) return if (node.type === 'leaf') { if (node.content?.kind === 'terminal' && node.content.status === 'creating' && node.content.createRequestId) { - addTerminalRestoreRequestId(node.content.createRequestId) + const fallbackAttempt = fallbackAttempts[tabId]?.[node.id] + if ( + fallbackAttempt?.requestId === node.content.createRequestId + && !node.content.sessionRef + ) { + addTerminalFreshRecoveryRequestId( + node.content.createRequestId, + 'fresh_after_restore_unavailable', + ) + } else if (node.content.sessionRef) { + addTerminalRestoreRequestId(node.content.createRequestId) + } } return } @@ -818,7 +851,16 @@ export default function App() { } })(layout) } - dispatch(setTerminalMetaSnapshot({ terminals: terminalMeta, requestedAt: Date.now() })) + dispatch(setTerminalMetaSnapshot({ terminals: terminalMeta, requestedAt: terminalMetaRequestedAt })) + dispatch(patchSessionRunningStateFromTerminalMeta({ + upsert: terminalMeta, + remove: removedTerminalMetaIds, + })) + void appStore.dispatch(fetchTerminalDirectoryWindow({ + surface: 'sidebar', + priority: 'visible', + }) as any) + void appStore.dispatch(queueActiveSessionWindowRefresh() as any) } if (msg.type === 'codex.activity.list.response') { const requestId = typeof msg.requestId === 'string' ? msg.requestId : '' @@ -878,6 +920,21 @@ export default function App() { })) } } + if (msg.type === 'terminal.turn.complete') { + const terminalId = typeof msg.terminalId === 'string' ? msg.terminalId : '' + const at = typeof msg.at === 'number' ? msg.at : Date.now() + if (terminalId) { + const location = selectTabPaneByTerminalId(appStore.getState(), terminalId) + if (location) { + dispatch(recordTurnComplete({ + tabId: location.tabId, + paneId: location.paneId, + terminalId, + at, + })) + } + } + } if (msg.type === 'terminal.exit') { const terminalId = msg.terminalId const code = msg.exitCode @@ -917,11 +974,13 @@ export default function App() { dispatch(updateServerStatus({ name: msg.name, serverRunning: false, serverPort: undefined })) } - // SDK message handling (freshclaude pane) + handleFreshAgentMessage(dispatch, msg as Record<string, unknown>, ws) + // SDK message handling (freshclaude compatibility surface) handleSdkMessage(dispatch, msg as Record<string, unknown>, ws) }) cleanup = () => { + terminalInvalidationHandler.dispose() stopWsDisconnectSync?.() unsubscribe() } diff --git a/src/components/MobileTabStrip.tsx b/src/components/MobileTabStrip.tsx index 927266d44..a2b5af0eb 100644 --- a/src/components/MobileTabStrip.tsx +++ b/src/components/MobileTabStrip.tsx @@ -5,11 +5,13 @@ import { getTabDisplayTitle } from '@/lib/tab-title' import { getBusyPaneIdsForTab } from '@/lib/pane-activity' import { triggerHapticFeedback } from '@/lib/mobile-haptics' import type { ChatSessionState } from '@/store/agentChatTypes' +import type { FreshAgentSessionState } from '@/store/freshAgentTypes' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' const EMPTY_CODEX_ACTIVITY_BY_ID = {} const EMPTY_OPENCODE_ACTIVITY_BY_ID = {} const EMPTY_AGENT_CHAT_SESSIONS: Record<string, ChatSessionState> = {} +const EMPTY_FRESH_AGENT_SESSIONS: Record<string, FreshAgentSessionState> = {} const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record<string, PaneRuntimeActivityRecord> = {} interface MobileTabStripProps { @@ -27,6 +29,7 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) @@ -46,6 +49,7 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb opencodeActivityByTerminalId, paneRuntimeActivityByPaneId, agentChatSessions, + freshAgentSessions, }).length > 0 : false diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4a9bb6dd3..b2316c061 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -19,7 +19,7 @@ import { getInstalledPerfAuditBridge } from '@/lib/perf-audit-bridge' import { fetchSessionWindow } from '@/store/sessionsThunks' import { mergeSessionMetadataByKey } from '@/lib/session-metadata' import { collectBusySessionKeys } from '@/lib/pane-activity' -import { selectPrimaryTerminalIdForTab } from '@/store/selectors/paneTerminalSelectors' +import { selectPrimaryTerminalIdForTab, selectTabIdByTerminalId } from '@/store/selectors/paneTerminalSelectors' import type { ChatSessionState } from '@/store/agentChatTypes' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' @@ -39,6 +39,23 @@ function sameSessionRef( return a.provider === b.provider && a.sessionId === b.sessionId } +function sameCodexDurability( + a?: BackgroundTerminal['codexDurability'], + b?: BackgroundTerminal['codexDurability'], +): boolean { + if (a === b) return true + if (!a || !b) return false + return a.state === b.state + && a.durableThreadId === b.durableThreadId + && a.candidate?.candidateThreadId === b.candidate?.candidateThreadId + && a.candidate?.rolloutPath === b.candidate?.rolloutPath + && a.turnCompletedAt === b.turnCompletedAt + && a.nonRestorableReason === b.nonRestorableReason + && a.lastProofFailure?.reason === b.lastProofFailure?.reason + && a.lastProofFailure?.message === b.lastProofFailure?.message + && a.lastProofFailure?.checkedAt === b.lastProofFailure?.checkedAt +} + /** Compare two BackgroundTerminal arrays by sidebar-relevant fields only. * Ignores terminal `lastActivityAt` since it changes frequently but doesn't affect rendering. */ export function areTerminalsEqual(a: BackgroundTerminal[], b: BackgroundTerminal[]): boolean { @@ -53,7 +70,8 @@ export function areTerminalsEqual(a: BackgroundTerminal[], b: BackgroundTerminal ai.status !== bi.status || ai.hasClients !== bi.hasClients || ai.mode !== bi.mode || - !sameSessionRef(ai.sessionRef, bi.sessionRef) + !sameSessionRef(ai.sessionRef, bi.sessionRef) || + !sameCodexDurability(ai.codexDurability, bi.codexDurability) ) return false } return true @@ -84,6 +102,10 @@ export function areSessionItemsEqual(a: SessionItem[], b: SessionItem[]): boolea ai.cwd !== bi.cwd || ai.projectPath !== bi.projectPath || ai.isFallback !== bi.isFallback || + ai.isRestorable !== bi.isRestorable || + !sameCodexDurability(ai.codexDurability, bi.codexDurability) || + ai.codexDurabilityState !== bi.codexDurabilityState || + ai.codexDurabilityReason !== bi.codexDurabilityReason || ai.timestamp !== bi.timestamp ) return false } @@ -140,10 +162,35 @@ function isSessionItemEqual(a: SessionItem, b: SessionItem): boolean { a.hasTitle === b.hasTitle && a.isSubagent === b.isSubagent && a.isNonInteractive === b.isNonInteractive && - a.firstUserMessage === b.firstUserMessage + a.firstUserMessage === b.firstUserMessage && + a.isRestorable === b.isRestorable && + sameCodexDurability(a.codexDurability, b.codexDurability) && + a.codexDurabilityState === b.codexDurabilityState && + a.codexDurabilityReason === b.codexDurabilityReason ) } +function getCodexDurabilityStatusLabel(item: SessionItem): string | undefined { + if (item.provider !== 'codex') return undefined + switch (item.codexDurabilityState) { + case 'identity_pending': + return 'Preparing restore' + case 'captured_pre_turn': + case 'turn_in_progress_unproven': + return 'Restore pending' + case 'proof_checking': + return 'Checking restore' + case 'durable_resuming': + return 'Restoring' + case 'durability_unproven_after_completion': + return 'Restore not verified' + case 'non_restorable': + return 'Not restorable' + default: + return undefined + } +} + /** * Determine whether a sidebar session item should be highlighted as active. * Prefers activeSessionKey (derived from the active pane's content) when @@ -333,6 +380,34 @@ export default function Sidebar({ const currentActiveTabId = state.tabs.activeTabId const runningTerminalId = item.isRunning ? item.runningTerminalId : undefined const localServerInstanceId = state.connection.serverInstanceId + const liveTerminal = runningTerminalId && localServerInstanceId + ? { terminalId: runningTerminalId, serverInstanceId: localServerInstanceId } + : undefined + + if (runningTerminalId && item.isRestorable === false) { + const existingTabId = selectTabIdByTerminalId(state, runningTerminalId) + if (existingTabId) { + dispatch(setActiveTab(existingTabId)) + const activePaneId = state.panes.activePane[existingTabId] + if (activePaneId) { + dispatch(setActivePane({ tabId: existingTabId, paneId: activePaneId })) + } + onNavigate('terminal') + return + } + dispatch(openSessionTab({ + sessionId: item.sessionId, + title: item.title, + cwd: item.cwd, + provider, + sessionType: item.sessionType || provider, + terminalId: runningTerminalId, + isRestorable: false, + codexDurability: item.codexDurability, + })) + onNavigate('terminal') + return + } // 1. Dedup: if session is already open in a pane, focus it const existing = findPaneForSession( @@ -367,9 +442,11 @@ export default function Sidebar({ provider, sessionType, terminalId: runningTerminalId, + isRestorable: item.isRestorable, firstUserMessage: item.firstUserMessage, isSubagent: item.isSubagent, isNonInteractive: item.isNonInteractive, + codexDurability: item.codexDurability, })) onNavigate('terminal') return @@ -385,22 +462,40 @@ export default function Sidebar({ provider, sessionType, terminalId: runningTerminalId, + isRestorable: item.isRestorable, firstUserMessage: item.firstUserMessage, isSubagent: item.isSubagent, isNonInteractive: item.isNonInteractive, + codexDurability: item.codexDurability, })) onNavigate('terminal') return } + const newContent = item.isRestorable === false + ? { + kind: 'terminal' as const, + mode: provider, + initialCwd: item.cwd, + codexDurability: provider === 'codex' ? item.codexDurability : undefined, + ...(liveTerminal + ? { + terminalId: liveTerminal.terminalId, + serverInstanceId: liveTerminal.serverInstanceId, + status: 'running' as const, + } + : {}), + } + : buildResumeContent({ + sessionType, + sessionId: item.sessionId, + cwd: item.cwd, + agentChatProviderSettings: providerSettings, + liveTerminal, + }) dispatch(addPane({ tabId: currentActiveTabId, - newContent: buildResumeContent({ - sessionType, - sessionId: item.sessionId, - cwd: item.cwd, - agentChatProviderSettings: providerSettings, - }), + newContent, })) const activeTab = state.tabs.tabs.find((tab) => tab.id === currentActiveTabId) const sessionMetadataByKey = mergeSessionMetadataByKey( @@ -414,7 +509,7 @@ export default function Sidebar({ isNonInteractive: item.isNonInteractive, }, ) - if (activeTab && sessionMetadataByKey !== activeTab.sessionMetadataByKey) { + if (activeTab && item.isRestorable !== false && sessionMetadataByKey !== activeTab.sessionMetadataByKey) { dispatch(updateTab({ id: currentActiveTabId, updates: { sessionMetadataByKey }, @@ -811,7 +906,11 @@ function areSidebarItemPropsEqual(prev: SidebarItemProps, next: SidebarItemProps a.projectColor === b.projectColor && a.cwd === b.cwd && a.projectPath === b.projectPath && - a.isFallback === b.isFallback + a.isFallback === b.isFallback && + a.isRestorable === b.isRestorable && + sameCodexDurability(a.codexDurability, b.codexDurability) && + a.codexDurabilityState === b.codexDurabilityState && + a.codexDurabilityReason === b.codexDurabilityReason ) } @@ -819,6 +918,7 @@ export const SidebarItem = memo(function SidebarItem(props: SidebarItemProps) { const { item, isActiveTab, isBusy = false, showProjectBadge, onClick } = props const extensionEntries = useAppSelector((s) => s.extensions?.entries) const { icon: SessionIcon, label: sessionLabel } = resolveSessionTypeConfig(item.sessionType, extensionEntries) + const codexStatusLabel = getCodexDurabilityStatusLabel(item) return ( <Tooltip> <TooltipTrigger asChild> @@ -834,7 +934,8 @@ export const SidebarItem = memo(function SidebarItem(props: SidebarItemProps) { data-session-id={item.sessionId} data-provider={item.provider} data-session-type={item.sessionType} - data-running-terminal-id={item.runningTerminalId} + data-is-running={item.isRunning ? 'true' : 'false'} + data-running-terminal-id={item.runningTerminalId ?? ''} data-has-tab={item.hasTab ? 'true' : 'false'} > {/* Provider icon */} @@ -851,7 +952,7 @@ export const SidebarItem = memo(function SidebarItem(props: SidebarItemProps) { {/* Content */} <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> + <div className="flex items-center gap-2 min-w-0"> <span className={cn( 'text-sm truncate', @@ -863,6 +964,11 @@ export const SidebarItem = memo(function SidebarItem(props: SidebarItemProps) { {item.archived && ( <Archive className="h-3 w-3 text-muted-foreground/70" aria-label="Archived session" /> )} + {codexStatusLabel && ( + <span className="text-2xs text-muted-foreground/70 flex-shrink-0"> + {codexStatusLabel} + </span> + )} </div> {item.subtitle && showProjectBadge && ( <div className="text-2xs text-muted-foreground truncate"> @@ -880,6 +986,11 @@ export const SidebarItem = memo(function SidebarItem(props: SidebarItemProps) { <TooltipContent> <div>{sessionLabel}: {item.title}</div> <div className="text-muted-foreground">{item.subtitle || item.projectPath || sessionLabel}</div> + {codexStatusLabel && ( + <div className="text-muted-foreground"> + {codexStatusLabel}{item.codexDurabilityReason ? `: ${item.codexDurabilityReason}` : ''} + </div> + )} </TooltipContent> </Tooltip> ) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 2d170f67b..f8ee1598c 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -7,7 +7,7 @@ import { getWsClient } from '@/lib/ws-client' import { getTabDisplayTitle } from '@/lib/tab-title' import { collectPaneEntries, collectTerminalIds } from '@/lib/pane-utils' import { getBusyPaneIdsForTab } from '@/lib/pane-activity' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTabBarScroll } from '@/hooks/useTabBarScroll' import TabItem from './TabItem' import { cancelCodingCliRequest } from '@/store/codingCliSlice' @@ -17,6 +17,7 @@ import { TabSwitcher } from './TabSwitcher' import { DndContext, closestCenter, + rectIntersection, KeyboardSensor, PointerSensor, TouchSensor, @@ -30,16 +31,25 @@ import { SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, + rectSortingStrategy, useSortable, } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' +import { CSS as DndCSS } from '@dnd-kit/utilities' import type { Tab, TabAttentionStyle } from '@/store/types' import type { PaneContent, PaneNode } from '@/store/paneTypes' import type { ChatSessionState } from '@/store/agentChatTypes' +import type { FreshAgentSessionState } from '@/store/freshAgentTypes' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' import { ContextIds } from '@/components/context-menu/context-menu-constants' import { applyTabRename } from '@/store/titleSync' +function escapeSelector(id: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(id) + } + return id.replace(/(["\\])/g, '\\$1') +} + interface SortableTabProps { tab: Tab displayTitle: string @@ -90,7 +100,7 @@ function SortableTab({ } = useSortable({ id: tab.id }) const style = { - transform: CSS.Transform.toString(transform), + transform: DndCSS.Transform.toString(transform), transition: transition || 'transform 150ms ease', } @@ -132,6 +142,7 @@ const EMPTY_ATTENTION: Record<string, boolean> = {} const EMPTY_CODEX_ACTIVITY_BY_ID = {} const EMPTY_OPENCODE_ACTIVITY_BY_ID = {} const EMPTY_AGENT_CHAT_SESSIONS: Record<string, ChatSessionState> = {} +const EMPTY_FRESH_AGENT_SESSIONS: Record<string, FreshAgentSessionState> = {} const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record<string, PaneRuntimeActivityRecord> = {} interface TabBarProps { @@ -154,6 +165,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) @@ -161,6 +173,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp const attentionDismiss = useAppSelector((s) => s.settings?.settings?.panes?.attentionDismiss ?? 'click') const iconsOnTabs = useAppSelector((s) => s.settings?.settings?.panes?.iconsOnTabs ?? true) const tabAttentionStyle = useAppSelector((s) => s.settings?.settings?.panes?.tabAttentionStyle ?? 'highlight') + const multirowTabs = useAppSelector((s) => s.settings?.settings?.panes?.multirowTabs ?? false) const extensions = useAppSelector((s) => s.extensions?.entries) const ws = useMemo(() => getWsClient(), []) @@ -213,7 +226,8 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp opencodeActivityByTerminalId, paneRuntimeActivityByPaneId, agentChatSessions, - }), [agentChatSessions, codexActivityByTerminalId, opencodeActivityByTerminalId, paneLayouts, paneRuntimeActivityByPaneId]) + freshAgentSessions, + }), [agentChatSessions, codexActivityByTerminalId, freshAgentSessions, opencodeActivityByTerminalId, paneLayouts, paneRuntimeActivityByPaneId]) const [renamingId, setRenamingId] = useState<string | null>(null) const [renameValue, setRenameValue] = useState('') @@ -369,11 +383,47 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp callbackRef, canScrollLeft, canScrollRight, + scrollToTab, handleArrowClick, startHoldScroll, stopHoldScroll, cancelHoldScroll, - } = useTabBarScroll(activeTabId, tabs.length) + } = useTabBarScroll(activeTabId, tabs.length, multirowTabs) + + // Container ref for multirow auto-scroll (scoped, not global DOM query) + const multirowContainerRef = useRef<HTMLDivElement | null>(null) + const combinedRef = useCallback((node: HTMLDivElement | null) => { + callbackRef(node) + multirowContainerRef.current = node + }, [callbackRef]) + + // Container-scoped scroll for active tab in multirow mode (vertical) + useEffect(() => { + if (!multirowTabs || !activeTabId) return + const container = multirowContainerRef.current + if (!container) return + const tabEl = container.querySelector(`[data-tab-id="${escapeSelector(activeTabId)}"]`) as HTMLElement | null + if (!tabEl) return + const containerRect = container.getBoundingClientRect() + const tabRect = tabEl.getBoundingClientRect() + // Only scroll if tab is outside the visible area + if (tabRect.top < containerRect.top || tabRect.bottom > containerRect.bottom) { + const offset = tabRect.top - containerRect.top - (containerRect.height / 2) + (tabRect.height / 2) + container.scrollBy({ top: offset, behavior: 'smooth' }) + } + }, [activeTabId, multirowTabs]) + + // Re-fire horizontal scroll when transitioning from multirow to single-row + const prevMultirowRef = useRef(multirowTabs) + useEffect(() => { + let raf: number | null = null + if (prevMultirowRef.current && !multirowTabs && activeTabId) { + // Defer to next frame so the DOM has re-rendered with single-row layout + raf = requestAnimationFrame(() => scrollToTab(activeTabId)) + } + prevMultirowRef.current = multirowTabs + return () => { if (raf !== null) cancelAnimationFrame(raf) } + }, [multirowTabs, activeTabId, scrollToTab]) const activeTab = activeId ? tabs.find((t: Tab) => t.id === activeId) : null @@ -395,22 +445,44 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp } return ( - <div className="relative z-20 h-12 md:h-10 shrink-0 flex items-end px-2 bg-background" data-context={ContextIds.Global}> + <div className={cn( + "relative z-20 shrink-0 flex items-end px-2 bg-background", + multirowTabs ? "h-auto" : "h-12 md:h-10" + )} data-context={ContextIds.Global}> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-muted-foreground/45" aria-hidden="true" /> + {sidebarCollapsed && onToggleSidebar && ( + <div + className={cn( + "flex-shrink-0 w-10 flex items-end justify-center pb-1", + !multirowTabs && "h-full" + )} + data-testid="desktop-sidebar-reopen-slot" + > + <button + className="p-1 min-h-11 min-w-11 md:h-8 md:w-8 md:min-h-0 md:min-w-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors" + title="Show sidebar" + aria-label="Show sidebar" + onClick={onToggleSidebar} + > + <PanelLeft className="h-3.5 w-3.5" /> + </button> + </div> + )} <DndContext sensors={sensors} - collisionDetection={closestCenter} + collisionDetection={multirowTabs ? rectIntersection : closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > <SortableContext items={tabs.map((t: Tab) => t.id)} - strategy={horizontalListSortingStrategy} + strategy={multirowTabs ? rectSortingStrategy : horizontalListSortingStrategy} > {/* Left scroll arrow -- flex sibling alongside the scroll container */} + {!multirowTabs && ( <button className={cn( 'flex-shrink-0 w-7 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-all duration-150', @@ -427,26 +499,24 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp > <ChevronLeft className="h-4 w-4" /> </button> + )} {/* Scrollable tab strip */} <div - ref={callbackRef} - className="flex items-end gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-none pt-px flex-1 min-w-0" - > - {sidebarCollapsed && onToggleSidebar && ( - <button - className="flex-shrink-0 mb-1 p-1 min-h-11 min-w-11 md:min-h-0 md:min-w-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors" - title="Show sidebar" - aria-label="Show sidebar" - onClick={onToggleSidebar} - > - <PanelLeft className="h-3.5 w-3.5" /> - </button> + ref={combinedRef} + data-testid="tab-strip" + className={cn( + "flex items-end gap-0.5 pt-px flex-1 min-w-0", + multirowTabs + ? "flex-wrap max-h-32 overflow-y-auto" + : "overflow-x-auto overflow-y-hidden scrollbar-none" )} + > {tabs.map(renderSortableTab)} </div> {/* Right scroll arrow -- flex sibling alongside the scroll container */} + {!multirowTabs && ( <button className={cn( 'flex-shrink-0 w-7 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-all duration-150', @@ -463,6 +533,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp > <ChevronRight className="h-4 w-4" /> </button> + )} </SortableContext> {/* Pinned + button -- outside the scrollable area */} diff --git a/src/components/TabSwitcher.tsx b/src/components/TabSwitcher.tsx index 86f0e7e69..cc2a79ecd 100644 --- a/src/components/TabSwitcher.tsx +++ b/src/components/TabSwitcher.tsx @@ -7,11 +7,13 @@ import { useCallback, useMemo } from 'react' import type { Tab, TerminalStatus } from '@/store/types' import { triggerHapticFeedback } from '@/lib/mobile-haptics' import type { ChatSessionState } from '@/store/agentChatTypes' +import type { FreshAgentSessionState } from '@/store/freshAgentTypes' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' const EMPTY_CODEX_ACTIVITY_BY_ID = {} const EMPTY_OPENCODE_ACTIVITY_BY_ID = {} const EMPTY_AGENT_CHAT_SESSIONS: Record<string, ChatSessionState> = {} +const EMPTY_FRESH_AGENT_SESSIONS: Record<string, FreshAgentSessionState> = {} const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record<string, PaneRuntimeActivityRecord> = {} interface TabSwitcherProps { @@ -48,6 +50,7 @@ export function TabSwitcher({ onClose }: TabSwitcherProps) { const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) @@ -107,6 +110,7 @@ export function TabSwitcher({ onClose }: TabSwitcherProps) { opencodeActivityByTerminalId, paneRuntimeActivityByPaneId, agentChatSessions, + freshAgentSessions, }).length > 0 return ( <button diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx index 5b036b353..44ad45fe9 100644 --- a/src/components/TabsView.tsx +++ b/src/components/TabsView.tsx @@ -21,6 +21,7 @@ import { addTab, setActiveTab } from '@/store/tabsSlice' import { addPane, initLayout } from '@/store/panesSlice' import { setTabRegistryLoading, setTabRegistrySearchRangeDays } from '@/store/tabRegistrySlice' import { selectTabsRegistryGroups } from '@/store/selectors/tabsRegistrySelectors' +import { getCurrentTabRegistryClientInstanceId } from '@/store/tabRegistrySync' import { isNonShellMode } from '@/lib/coding-cli-utils' import { copyText } from '@/lib/clipboard' import { cn } from '@/lib/utils' @@ -35,6 +36,8 @@ import { import type { CodingCliProviderName, TabMode } from '@/store/types' import type { AgentChatProviderName } from '@/lib/agent-chat-types' import { migrateLegacyAgentChatDurableState } from '@shared/session-contract' +import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' +import { normalizeFreshAgentSessionType, resolveFreshAgentRuntimeProvider } from '@shared/fresh-agent' /* ------------------------------------------------------------------ */ /* Types */ @@ -111,11 +114,15 @@ function sanitizePaneSnapshot( const mode = (payload.mode as TabMode) || 'shell' const sessionRef = resolveSessionRef({ payload }) const liveTerminal = parseLiveTerminalHandle(payload.liveTerminal, record.serverInstanceId) + const codexDurability = mode === 'codex' + ? sanitizeCodexDurabilityRef(payload.codexDurability) + : undefined return { kind: 'terminal', mode, shell: (payload.shell as 'system' | 'cmd' | 'powershell' | 'wsl') || 'system', sessionRef, + ...(codexDurability ? { codexDurability } : {}), terminalId: sameServer ? liveTerminal?.terminalId : undefined, serverInstanceId: record.serverInstanceId, initialCwd: payload.initialCwd as string | undefined, @@ -159,6 +166,41 @@ function sanitizePaneSnapshot( plugins: payload.plugins as string[] | undefined, } } + if (snapshot.kind === 'fresh-agent') { + const sessionType = normalizeFreshAgentSessionType(payload.sessionType) + ?? normalizeFreshAgentSessionType(payload.provider) + const provider = ( + payload.provider === 'claude' + || payload.provider === 'codex' + || payload.provider === 'opencode' + ) + ? payload.provider + : resolveFreshAgentRuntimeProvider(sessionType) + if (!sessionType || !provider) return { kind: 'picker' } + const resumeSessionId = typeof payload.resumeSessionId === 'string' + ? payload.resumeSessionId + : undefined + const sessionRef = resolveSessionRef({ + payload, + fallbackProvider: provider, + fallbackSessionId: resumeSessionId, + }) + return { + kind: 'fresh-agent', + sessionType, + provider, + resumeSessionId, + ...(sessionRef ? { sessionRef } : {}), + serverInstanceId: record.serverInstanceId, + initialCwd: payload.initialCwd as string | undefined, + model: payload.model as string | undefined, + modelSelection: normalizeAgentChatModelSelection(payload.modelSelection, payload.model), + permissionMode: payload.permissionMode as string | undefined, + sandbox: payload.sandbox as 'read-only' | 'workspace-write' | 'danger-full-access' | undefined, + effort: normalizeAgentChatEffortOverride(payload.effort), + plugins: payload.plugins as string[] | undefined, + } + } if (snapshot.kind === 'extension') { return { kind: 'extension', @@ -177,6 +219,11 @@ function deriveModeFromRecord(record: RegistryTabRecord): TabMode { return 'shell' } if (firstKind === 'agent-chat') return 'claude' + if (firstKind === 'fresh-agent') { + const provider = record.panes[0]?.payload?.provider + if (typeof provider === 'string' && isNonShellMode(provider)) return provider as TabMode + return 'claude' + } return 'shell' } @@ -184,7 +231,7 @@ function paneKindIcon(kind: RegistryPaneSnapshot['kind']): LucideIcon { if (kind === 'terminal') return TerminalSquare if (kind === 'browser') return Globe if (kind === 'editor') return FileCode2 - if (kind === 'agent-chat') return Bot + if (kind === 'agent-chat' || kind === 'fresh-agent') return Bot return Square } @@ -192,7 +239,7 @@ function paneKindColorClass(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'terminal') return 'text-foreground/50' if (kind === 'browser') return 'text-blue-500' if (kind === 'editor') return 'text-emerald-500' - if (kind === 'agent-chat' || kind === 'claude-chat') return 'text-amber-500' + if (kind === 'agent-chat' || kind === 'fresh-agent' || kind === 'claude-chat') return 'text-amber-500' if (kind === 'extension') return 'text-purple-500' return 'text-muted-foreground' } @@ -201,7 +248,7 @@ function paneKindLabel(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'terminal') return 'Terminal' if (kind === 'browser') return 'Browser' if (kind === 'editor') return 'Editor' - if (kind === 'agent-chat' || kind === 'claude-chat') return 'Agent' + if (kind === 'agent-chat' || kind === 'fresh-agent' || kind === 'claude-chat') return 'Agent' if (kind === 'extension') return 'Extension' return kind } @@ -525,7 +572,8 @@ function TabsView({ onOpenTab }: { onOpenTab?: () => void }) { ws.sendTabsSyncQuery({ requestId: `tabs-range-${Date.now()}`, deviceId, - rangeDays: searchRangeDays, + clientInstanceId: getCurrentTabRegistryClientInstanceId(), + closedTabRetentionDays: searchRangeDays, }) }, [dispatch, ws, deviceId, searchRangeDays]) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index def853dc9..bcc60c1c1 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from '@/store/hooks' import { updateTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' import { consumePaneRefreshRequest, splitPane, updatePaneContent, updatePaneTitle } from '@/store/panesSlice' import { updateSessionActivity } from '@/store/sessionActivitySlice' +import { recordPaneTabActivity } from '@/store/tabRecencySlice' import { updateSettingsLocal } from '@/store/settingsSlice' import { clearPaneRuntimeActivity, setPaneRuntimeActivity } from '@/store/paneRuntimeActivitySlice' import { recordTurnComplete, clearTabAttention, clearPaneAttention } from '@/store/turnCompletionSlice' @@ -26,7 +27,13 @@ import { getCreateSessionStateFromRef } from '@/components/terminal-view-utils' import { copyText, readText } from '@/lib/clipboard' import { registerTerminalActions } from '@/lib/pane-action-registry' import { registerTerminalCaptureHandler } from '@/lib/screenshot-capture-env' -import { consumeTerminalRestoreRequestId, addTerminalRestoreRequestId } from '@/lib/terminal-restore' +import { + addTerminalFreshRecoveryRequestId, + addTerminalRestoreRequestId, + consumeTerminalFreshRecoveryRequest, + consumeTerminalRestoreRequestId, + type TerminalFreshRecoveryIntent, +} from '@/lib/terminal-restore' import { isTerminalPasteShortcut } from '@/lib/terminal-input-policy' import { clearTerminalCursor, loadTerminalCursor, saveTerminalCursor } from '@/lib/terminal-cursor' import { paneRefreshTargetMatchesContent } from '@/lib/pane-utils' @@ -46,6 +53,7 @@ import { findLocalFilePaths } from '@/lib/path-utils' import { findUrls } from '@/lib/url-utils' import { setHoveredUrl, clearHoveredUrl } from '@/lib/terminal-hovered-url' import { getTabSwitchShortcutDirection, getTabLifecycleAction } from '@/lib/tab-switch-shortcuts' +import { bucketTabRecencyAt } from '@/lib/tab-recency' import { createTurnCompleteSignalParserState, extractTurnCompleteSignals, @@ -111,6 +119,36 @@ const LIGHT_THEME_MIN_CONTRAST_RATIO = 4.5 const DEFAULT_MIN_CONTRAST_RATIO = 1 const MAX_LAST_SENT_VIEWPORT_CACHE_ENTRIES = 200 const TRUNCATED_REPLAY_BYTES = 128 * 1024 +const INPUT_BLOCKED_NOTICE_THROTTLE_MS = 2000 + +function viewportHydrateReplayOptions(content?: TerminalPaneContent | null): { maxReplayBytes: number } | undefined { + return content?.mode === 'opencode' + ? undefined + : { maxReplayBytes: TRUNCATED_REPLAY_BYTES } +} + +type TerminalInputBlockedReason = + | 'codex_identity_pending' + | 'codex_identity_capture_timeout' + | 'codex_identity_unavailable' + | 'codex_recovery_pending' + +function shouldSuppressNativeTouchScroll(term: Terminal): boolean { + return term.buffer.active.type === 'alternate' && term.modes.mouseTrackingMode !== 'none' +} + +function terminalInputBlockedNotice(reason: TerminalInputBlockedReason): string { + switch (reason) { + case 'codex_identity_pending': + return 'Input not sent: Codex is still saving restore state. Try again in a moment.' + case 'codex_recovery_pending': + return 'Input not sent: Codex is still reconnecting. Try again in a moment.' + case 'codex_identity_capture_timeout': + return 'Input not sent: Codex did not provide restore state before startup timed out. Start a new Codex pane or resume inside Codex.' + case 'codex_identity_unavailable': + return 'Input not sent: Codex did not provide restorable session state. Start a new Codex pane or resume inside Codex.' + } +} type StartupProbeReplayDiscardState = { remainder: string | null @@ -210,6 +248,7 @@ type LaunchAttemptState = { requestId: string terminalId?: string restore: boolean + recoveryIntent?: TerminalFreshRecoveryIntent attachReady: boolean } @@ -287,11 +326,13 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const dispatch = useAppDispatch() const isMobile = useMobile() const connectionStatus = useAppSelector((s) => s.connection.status) + const serverInstanceId = useAppSelector((s) => s.connection.serverInstanceId) const tab = useAppSelector((s) => s.tabs.tabs.find((t) => t.id === tabId)) const tabHasSinglePane = useAppSelector((s) => s.panes.layouts[tabId]?.type === 'leaf') const activeTabId = useAppSelector((s) => s.tabs.activeTabId) const tabOrder = useAppSelector((s) => s.tabs.tabs.map((t) => t.id), shallowEqual) const activePaneId = useAppSelector((s) => s.panes.activePane[tabId]) + const paneLastInputAt = useAppSelector((s) => s.tabRecency?.paneLastInputAt?.[paneId]) const refreshRequest = useAppSelector((s) => s.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null) const connectionErrorCode = useAppSelector((s) => s.connection.lastErrorCode) const settings = useAppSelector((s) => s.settings.settings) @@ -335,9 +376,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const hiddenRef = useRef(hidden) const hydrationRegisteredRef = useRef(false) const lastSessionActivityAtRef = useRef(0) + const paneLastInputAtRef = useRef<number | undefined>(paneLastInputAt) const rateLimitRetryRef = useRef<{ count: number; timer: ReturnType<typeof setTimeout> | null }>({ count: 0, timer: null }) const restoreRequestIdRef = useRef<string | null>(null) const restoreFlagRef = useRef(false) + const freshRecoveryRequestIdRef = useRef<string | null>(null) + const freshRecoveryIntentRef = useRef<TerminalFreshRecoveryIntent | undefined>(undefined) const turnCompleteSignalStateRef = useRef(createTurnCompleteSignalParserState()) const startupProbeStateRef = useRef(createTerminalStartupProbeState()) const startupProbeReplayDiscardStateRef = useRef<StartupProbeReplayDiscardState>({ @@ -368,6 +412,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const tapCountRef = useRef(0) const terminalFirstOutputMarkedRef = useRef(false) const turnCompletedSinceLastInputRef = useRef(true) + const lastInputBlockedNoticeRef = useRef<{ reason: TerminalInputBlockedReason; at: number } | null>(null) // Extract terminal-specific fields (safe because we check kind later) const isTerminal = paneContent.kind === 'terminal' @@ -416,6 +461,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) requestId: string terminalId: string } | null>(null) + const serverInstanceIdRef = useRef(serverInstanceId) const searchTerminalIdCleanupRef = useRef<string | null>(terminalContent?.terminalId ?? null) const deferredAttachStateRef = useRef<DeferredAttachState>({ mode: 'none', @@ -536,11 +582,16 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) // Sync during render (not in useEffect) so refs always have latest values hasAttentionRef.current = hasAttention hasPaneAttentionRef.current = hasPaneAttention + paneLastInputAtRef.current = paneLastInputAt attentionDismissRef.current = settings.panes?.attentionDismiss ?? 'click' debugRef.current = !!settings.logging?.debug refreshRequestRef.current = refreshRequest providerBehaviorRef.current = providerBehavior + useEffect(() => { + serverInstanceIdRef.current = serverInstanceId + }, [serverInstanceId]) + const shouldFocusActiveTerminal = !hidden && activeTabId === tabId && activePaneId === paneId // Keep the active pane's terminal focused when tabs/panes switch so typing works immediately. @@ -705,7 +756,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const rawLines = touchScrollAccumulatorRef.current / TOUCH_SCROLL_PIXELS_PER_LINE const lines = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines) if (lines !== 0) { - if (!translateScrollLinesToInput(term, lines)) { + if (!translateScrollLinesToInput(term, lines) && !shouldSuppressNativeTouchScroll(term)) { term.scrollLines(lines) } @@ -1322,7 +1373,17 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const currentContent = contentRef.current if (currentTab) { const now = Date.now() - dispatch(updateTab({ id: currentTab.id, updates: { lastInputAt: now } })) + const bucket = bucketTabRecencyAt(now) + if ( + bucket !== undefined + && ( + paneLastInputAtRef.current === undefined + || bucket > paneLastInputAtRef.current + ) + ) { + paneLastInputAtRef.current = bucket + dispatch(recordPaneTabActivity({ paneId: paneIdRef.current, at: now })) + } const resumeSessionId = currentContent?.resumeSessionId if (resumeSessionId && currentContent?.mode && currentContent.mode !== 'shell') { if (now - lastSessionActivityAtRef.current >= SESSION_ACTIVITY_THROTTLE_MS) { @@ -1640,7 +1701,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } setIsAttaching(false) } else { - attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true, maxReplayBytes: TRUNCATED_REPLAY_BYTES }) + attachTerminal(tid, 'viewport_hydrate', { + clearViewportFirst: true, + ...viewportHydrateReplayOptions(currentContent), + }) } dispatch(consumePaneRefreshRequest({ tabId, paneId, requestId: request.requestId })) @@ -1689,7 +1753,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) clearViewportFirst: deferred.pendingIntent === 'viewport_hydrate', suppressNextMatchingResize: true, skipPreAttachFit: true, - ...(deferred.pendingIntent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : {}), + ...(deferred.pendingIntent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined), }) return } @@ -1744,12 +1810,22 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) return restoreFlagRef.current } + const getFreshRecoveryIntent = (requestId: string) => { + if (freshRecoveryRequestIdRef.current !== requestId) { + freshRecoveryRequestIdRef.current = requestId + freshRecoveryIntentRef.current = consumeTerminalFreshRecoveryRequest(requestId) + } + return freshRecoveryIntentRef.current + } + const sendCreate = (requestId: string) => { - const restore = getRestoreFlag(requestId) + const recoveryIntent = getFreshRecoveryIntent(requestId) + const restore = recoveryIntent ? false : getRestoreFlag(requestId) const createSessionState = getCreateSessionStateFromRef(contentRef) launchAttemptRef.current = { requestId, restore, + ...(recoveryIntent ? { recoveryIntent } : {}), attachReady: false, } if (handledCreatedMessageRef.current?.requestId === requestId) { @@ -1761,7 +1837,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) sessionRef: createSessionState.sessionRef, liveTerminal: createSessionState.liveTerminal, contentRefResumeSessionId: contentRef.current?.resumeSessionId, + codexDurability: createSessionState.codexDurability, mode, + recoveryIntent, }) ws.send({ type: 'terminal.create', @@ -1769,11 +1847,13 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) mode, shell: shell || 'system', cwd: initialCwd, - ...(createSessionState.sessionRef ? { sessionRef: createSessionState.sessionRef } : {}), - ...(createSessionState.liveTerminal ? { liveTerminal: createSessionState.liveTerminal } : {}), + ...(!recoveryIntent && createSessionState.sessionRef ? { sessionRef: createSessionState.sessionRef } : {}), + ...(!recoveryIntent && createSessionState.codexDurability ? { codexDurability: createSessionState.codexDurability } : {}), + ...(!recoveryIntent && createSessionState.liveTerminal ? { liveTerminal: createSessionState.liveTerminal } : {}), tabId, paneId: paneIdRef.current, ...(restore ? { restore: true } : {}), + ...(recoveryIntent ? { recoveryIntent } : {}), }) } @@ -2023,6 +2103,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) requestId: reqId, terminalId: newId, restore: pendingLaunch?.requestId === reqId ? pendingLaunch.restore : false, + ...(pendingLaunch?.requestId === reqId && pendingLaunch.recoveryIntent + ? { recoveryIntent: pendingLaunch.recoveryIntent } + : {}), attachReady: false, } currentAttachRef.current = null @@ -2033,11 +2116,23 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) currentResumeSessionId: contentRef.current?.resumeSessionId, }) terminalIdRef.current = newId - updateContent({ terminalId: newId, status: 'running' }) + updateContent({ + terminalId: newId, + serverInstanceId: serverInstanceIdRef.current, + status: 'running', + ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), + ...(msg.restoreError ? { restoreError: msg.restoreError } : {}), + }) // Also update tab status const currentTab = tabRef.current if (currentTab) { - dispatch(updateTab({ id: currentTab.id, updates: { status: 'running' } })) + dispatch(updateTab({ + id: currentTab.id, + updates: { + status: 'running', + ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), + }, + })) } applySeqState(createAttachSeqState({ lastSeq: 0 })) @@ -2141,17 +2236,72 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) paneResumeSessionId: contentRef.current?.resumeSessionId, tabResumeSessionId: currentTab?.resumeSessionId, }) + const paneCodexDurability = contentRef.current?.codexDurability + const nextPaneCodexDurability = sessionRef.provider === 'codex' + && paneCodexDurability?.state === 'durable' + && ( + paneCodexDurability.durableThreadId === sessionRef.sessionId + || paneCodexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ? paneCodexDurability + : undefined + const tabCodexDurability = currentTab?.codexDurability + const nextTabCodexDurability = sessionRef.provider === 'codex' + && tabCodexDurability?.state === 'durable' + && ( + tabCodexDurability.durableThreadId === sessionRef.sessionId + || tabCodexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ? tabCodexDurability + : undefined if (durableIdentityUpdate?.paneUpdates) { - updateContent(durableIdentityUpdate.paneUpdates) + updateContent({ ...durableIdentityUpdate.paneUpdates, codexDurability: nextPaneCodexDurability }) } if (currentTab && durableIdentityUpdate?.tabUpdates) { - dispatch(updateTab({ id: currentTab.id, updates: durableIdentityUpdate.tabUpdates })) + dispatch(updateTab({ id: currentTab.id, updates: { ...durableIdentityUpdate.tabUpdates, codexDurability: nextTabCodexDurability } })) } if (durableIdentityUpdate?.shouldFlush) { dispatch(flushPersistedLayoutNow()) } } + if (msg.type === 'terminal.codex.durability.updated' && msg.terminalId === tid) { + const durability = msg.durability + updateContent({ codexDurability: durability }) + const currentTab = tabHasSinglePaneRef.current ? tabRef.current : undefined + if (currentTab) { + dispatch(updateTab({ id: currentTab.id, updates: { codexDurability: durability } })) + } + dispatch(flushPersistedLayoutNow()) + const candidate = durability?.candidate + if (candidate) { + ws.send({ + type: 'terminal.codex.candidate.persisted', + terminalId: tid, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + capturedAt: candidate.capturedAt, + }) + } + } + + if (msg.type === 'terminal.input.blocked' && msg.terminalId === tid) { + const reason = msg.reason as TerminalInputBlockedReason + log.warn('terminal_input_blocked', { + tabId, + paneId: paneIdRef.current, + terminalId: tid, + reason, + }) + const now = Date.now() + const previous = lastInputBlockedNoticeRef.current + if (!previous || previous.reason !== reason || now - previous.at >= INPUT_BLOCKED_NOTICE_THROTTLE_MS) { + lastInputBlockedNoticeRef.current = { reason, at: now } + term.writeln(`\r\n[${terminalInputBlockedNotice(reason)}]\r\n`) + } + return + } + if (msg.type === 'error' && msg.requestId === reqId) { if (msg.code === 'RATE_LIMITED') { const scheduled = scheduleRateLimitRetry(reqId) @@ -2166,7 +2316,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) clearRateLimitRetry() setIsAttaching(false) dispatch(clearPaneRuntimeActivity({ paneId: paneIdRef.current })) - updateContent({ status: 'error' }) + updateContent({ + status: 'error', + ...(launchAttempt?.recoveryIntent + ? { restoreError: buildRestoreError('dead_live_handle') } + : {}), + }) const currentTab = tabRef.current if (currentTab) { dispatch(updateTab({ id: currentTab.id, updates: { status: 'error' } })) @@ -2211,7 +2366,8 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) // This prevents an infinite respawn loop when terminals fail immediately // (e.g., due to permission errors on cwd). User must explicitly restart. if (currentTerminalId && current?.status !== 'exited') { - if (!current?.sessionRef) { + const hasCodexCapturedRestoreState = current?.mode === 'codex' && Boolean(current.codexDurability?.candidate) + if (!current?.sessionRef && !hasCodexCapturedRestoreState) { const restoreDiagnostic = { event: 'restore_unavailable' as const, reason: 'dead_live_handle' as const, @@ -2228,19 +2384,35 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) type: 'client.diagnostic', ...restoreDiagnostic, }) - term.writeln('\r\n[Restore unavailable - the live terminal is gone and no durable session identity was saved]\r\n') + term.writeln('\r\n[Starting a new terminal because the previous live terminal is gone and no durable session identity was saved]\r\n') + const newRequestId = nanoid() launchAttemptRef.current = null clearRateLimitRetry() setIsAttaching(false) dispatch(clearPaneRuntimeActivity({ paneId: paneIdRef.current })) + consumeTerminalRestoreRequestId(requestIdRef.current) + addTerminalFreshRecoveryRequestId(newRequestId, 'fresh_after_restore_unavailable') + requestIdRef.current = newRequestId + clearTerminalCursor(currentTerminalId) + forgetSentViewport(currentTerminalId) + lastSentViewportRef.current = null + terminalIdRef.current = undefined + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + } + applySeqState(createAttachSeqState()) updateContent({ terminalId: undefined, - status: 'error', - restoreError: buildRestoreError('dead_live_handle'), + serverInstanceId: undefined, + createRequestId: newRequestId, + status: 'creating', + restoreError: undefined, }) const currentTab = tabRef.current if (currentTab) { - dispatch(updateTab({ id: currentTab.id, updates: { status: 'error' } })) + dispatch(updateTab({ id: currentTab.id, updates: { status: 'creating' } })) } return } @@ -2271,7 +2443,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) pendingSinceSeq: 0, } applySeqState(createAttachSeqState()) - updateContent({ terminalId: undefined, createRequestId: newRequestId, status: 'creating' }) + updateContent({ + terminalId: undefined, + serverInstanceId: undefined, + createRequestId: newRequestId, + status: 'creating', + }) const currentTab = tabRef.current if (currentTab) { dispatch(updateTab({ id: currentTab.id, updates: { status: 'creating' } })) @@ -2342,7 +2519,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const intent: AttachIntent = deferredAttachStateRef.current.mode === 'live' ? 'keepalive_delta' : 'viewport_hydrate' - attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : undefined) + attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined) } } else { deferredAttachStateRef.current = { diff --git a/src/components/agent-chat/AgentChatView.tsx b/src/components/agent-chat/AgentChatView.tsx index 5112fea10..cb4c8e4a2 100644 --- a/src/components/agent-chat/AgentChatView.tsx +++ b/src/components/agent-chat/AgentChatView.tsx @@ -56,6 +56,7 @@ import { } from '@/store/persistControl' import { useMobile } from '@/hooks/useMobile' import { useKeyboardInset } from '@/hooks/useKeyboardInset' +import { buildRestoreError } from '@shared/session-contract' /** Early lifecycle states that should not be re-entered once the session has advanced. */ const EARLY_STATES = new Set(['creating', 'starting']) @@ -86,6 +87,16 @@ function paneMatchesCurrentProviderDefaults( && pane.effort === providerDefaults?.effort } +function getCanonicalPaneResumeSessionId(pane: AgentChatPaneContent): string | undefined { + if (pane.sessionRef?.provider === 'claude' && isValidClaudeSessionId(pane.sessionRef.sessionId)) { + return pane.sessionRef.sessionId + } + if (isValidClaudeSessionId(pane.resumeSessionId)) { + return pane.resumeSessionId + } + return undefined +} + interface AgentChatViewProps { tabId: string paneId: string @@ -177,18 +188,30 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag const surfaceVisibleMarkedRef = useRef(false) const sessionRef = useRef(session) sessionRef.current = session - const persistedTimelineSessionId = isValidClaudeSessionId(paneContent.resumeSessionId) + const paneSessionRefResumeId = paneContent.sessionRef?.provider === 'claude' + ? paneContent.sessionRef.sessionId + : undefined + const canonicalPaneSessionRefResumeId = isValidClaudeSessionId(paneSessionRefResumeId) + ? paneSessionRefResumeId + : undefined + const persistedResumeSessionId = typeof paneContent.resumeSessionId === 'string' + && paneContent.resumeSessionId.trim().length > 0 ? paneContent.resumeSessionId : undefined - const canonicalDurableSessionId = getCanonicalDurableSessionId(session) ?? persistedTimelineSessionId - const timelineSessionId = getPreferredResumeSessionId(session) ?? persistedTimelineSessionId + const persistedCanonicalResumeSessionId = isValidClaudeSessionId(persistedResumeSessionId) + ? persistedResumeSessionId + : undefined + const canonicalDurableSessionId = getCanonicalDurableSessionId(session) + ?? canonicalPaneSessionRefResumeId + ?? persistedCanonicalResumeSessionId + const preferredSessionResumeSessionId = getPreferredResumeSessionId(session) + const timelineSessionId = preferredSessionResumeSessionId + ?? paneSessionRefResumeId + ?? persistedResumeSessionId const restoreHistoryQueryId = timelineSessionId ?? paneContent.sessionId const attachResumeSessionId = getPreferredResumeSessionId(session) - ?? ( - typeof paneContent.resumeSessionId === 'string' && paneContent.resumeSessionId.trim().length > 0 - ? paneContent.resumeSessionId - : undefined - ) + ?? paneSessionRefResumeId + ?? persistedResumeSessionId const attachPayload = useMemo(() => { if (!paneContent.sessionId) return null return { @@ -236,12 +259,29 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag ) const isRestoring = !!paneContent.sessionId && !session?.historyLoaded && !hasRestoreFailure - // Shared recovery logic: clears stale sessionId and resets to 'creating' so a new - // SDK session is spawned. Preserves resumeSessionId for CLI session continuity. + // Shared recovery logic for a lost live SDK handle. Only canonical Claude ids + // can be used for automatic recovery; mutable names are display state, not a + // deterministic restore target. const triggerRecovery = useCallback(() => { + const resumeSessionId = getCanonicalDurableSessionId(sessionRef.current) + ?? getCanonicalPaneResumeSessionId(paneContentRef.current) + if (!resumeSessionId) { + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + sessionId: undefined, + status: 'idle' as const, + restoreError: buildRestoreError('dead_live_handle'), + }, + })) + createSentRef.current = false + attachSentRef.current = false + return + } + const newRequestId = nanoid() - const resumeSessionId = getPreferredResumeSessionId(sessionRef.current) - ?? paneContentRef.current.resumeSessionId dispatch(updatePaneContent({ tabId, paneId, @@ -249,8 +289,10 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag ...paneContentRef.current, sessionId: undefined, resumeSessionId, + sessionRef: { provider: 'claude', sessionId: resumeSessionId }, createRequestId: newRequestId, status: 'creating' as const, + restoreError: undefined, }, })) createSentRef.current = false diff --git a/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index 128bc47dc..a5a11180f 100644 --- a/src/components/context-menu/ContextMenuProvider.tsx +++ b/src/components/context-menu/ContextMenuProvider.tsx @@ -30,6 +30,7 @@ import type { ClientExtensionEntry } from '@shared/extension-types' import { buildResumeContent } from '@/lib/session-type-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' import { mergeSessionMetadataByKey } from '@/lib/session-metadata' +import { deriveTabRecencyAt } from '@/lib/tab-recency' import { ConfirmModal } from '@/components/ui/confirm-modal' import type { AppView } from '@/components/Sidebar' import type { CodingCliProviderName, CodingCliSession, ProjectGroup } from '@/store/types' @@ -51,6 +52,8 @@ import { nanoid } from 'nanoid' const CONTEXT_MENU_KEYS = ['ContextMenu'] const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] +const EMPTY_PANE_LAST_INPUT_AT: Record<string, number | undefined> = {} +const EMPTY_FEATURE_FLAGS: Record<string, boolean> = {} type MenuState = { @@ -107,9 +110,10 @@ export function ContextMenuProvider({ const historySessions = useAppSelector((s) => s.sessions.windows?.history?.projects ?? s.sessions.projects) const expandedProjects = useAppSelector((s) => s.sessions.expandedProjects) const platform = useAppSelector((s) => s.connection?.platform ?? null) - const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? {}) + const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? EMPTY_FEATURE_FLAGS) const appSettings = useAppSelector((s) => s.settings.settings) const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES) + const paneLastInputAt = useAppSelector((s) => s.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT) const [menuState, setMenuState] = useState<MenuState | null>(null) const [confirmState, setConfirmState] = useState<ConfirmState | null>(null) @@ -523,7 +527,14 @@ export function ContextMenuProvider({ return refs.some((ref) => ref.provider === keyProvider && ref.sessionId === sessionId) }) const hasTab = relatedTabs.length > 0 - const tabLastInputAt = relatedTabs.reduce((max, tab) => Math.max(max, tab.lastInputAt ?? 0), 0) || undefined + const tabLastInputAt = relatedTabs.reduce((max, tab) => { + const layout = panes[tab.id] + return Math.max(max, deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt, + })) + }, 0) const runningTerminalId = menuState?.target.kind === 'sidebar-session' && menuState?.target.sessionId === sessionId ? menuState?.target.runningTerminalId @@ -544,14 +555,14 @@ export function ContextMenuProvider({ archived: session.archived, sourceFile: session.sourceFile, hasTab, - tabLastInputAt, - tabLastInputAtIso: tabLastInputAt ? new Date(tabLastInputAt).toISOString() : null, + tabLastInputAt: hasTab ? tabLastInputAt : undefined, + tabLastInputAtIso: hasTab ? new Date(tabLastInputAt).toISOString() : null, isRunning: !!runningTerminalId, runningTerminalId: runningTerminalId || null, projectColor: project.color, } await copyText(JSON.stringify(metadata, null, 2)) - }, [getSessionInfo, tabsState.tabs, panes, menuState?.target]) + }, [getSessionInfo, tabsState.tabs, panes, paneLastInputAt, menuState?.target]) const copyResumeCommand = useCallback(async (provider: ResumeCommandProvider, sessionId: string) => { const command = buildResumeCommand(provider, sessionId, extensionEntries) diff --git a/src/components/context-menu/context-menu-constants.ts b/src/components/context-menu/context-menu-constants.ts index 1dc9aee4e..8ad673b60 100644 --- a/src/components/context-menu/context-menu-constants.ts +++ b/src/components/context-menu/context-menu-constants.ts @@ -14,6 +14,7 @@ export const ContextIds = { OverviewTerminal: 'overview-terminal', ClaudeMessage: 'claude-message', AgentChat: 'agent-chat', + FreshAgent: 'fresh-agent', } as const export type ContextId = typeof ContextIds[keyof typeof ContextIds] diff --git a/src/components/context-menu/context-menu-types.ts b/src/components/context-menu/context-menu-types.ts index b115ddbdc..c5e759df7 100644 --- a/src/components/context-menu/context-menu-types.ts +++ b/src/components/context-menu/context-menu-types.ts @@ -17,6 +17,7 @@ export type ContextTarget = | { kind: 'overview-terminal'; terminalId: string } | { kind: 'claude-message'; sessionId: string; provider?: string } | { kind: 'agent-chat'; sessionId: string } + | { kind: 'fresh-agent'; sessionId: string } export type ParsedContext = { id: ContextId diff --git a/src/components/context-menu/context-menu-utils.ts b/src/components/context-menu/context-menu-utils.ts index 53ffcc745..0009d0ec5 100644 --- a/src/components/context-menu/context-menu-utils.ts +++ b/src/components/context-menu/context-menu-utils.ts @@ -86,6 +86,8 @@ export function parseContextTarget(contextId: ContextId, data: ContextDataset): return data.sessionId ? { kind: 'claude-message', sessionId: data.sessionId, provider: data.provider } : null case ContextIds.AgentChat: return data.sessionId ? { kind: 'agent-chat', sessionId: data.sessionId } : null + case ContextIds.FreshAgent: + return data.sessionId ? { kind: 'fresh-agent', sessionId: data.sessionId } : null default: return null } diff --git a/src/components/context-menu/menu-defs.ts b/src/components/context-menu/menu-defs.ts index 8415fdb24..a2ffb24dc 100644 --- a/src/components/context-menu/menu-defs.ts +++ b/src/components/context-menu/menu-defs.ts @@ -594,7 +594,7 @@ export function buildMenuItems(target: ContextTarget, ctx: MenuBuildContext): Me ] } - if (target.kind === 'agent-chat') { + if (target.kind === 'agent-chat' || target.kind === 'fresh-agent') { const selection = window.getSelection() const hasSelection = !!(selection && selection.toString().trim()) diff --git a/src/components/fresh-agent/FreshAgentApprovalBanner.tsx b/src/components/fresh-agent/FreshAgentApprovalBanner.tsx new file mode 100644 index 000000000..73d8d8ba6 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentApprovalBanner.tsx @@ -0,0 +1,7 @@ +export function FreshAgentApprovalBanner({ text }: { text: string }) { + return ( + <div role="alert" className="rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm"> + {text} + </div> + ) +} diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx new file mode 100644 index 000000000..c11669168 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -0,0 +1,39 @@ +type FreshAgentComposerProps = { + disabled?: boolean + onSend?: (value: string) => void +} + +export function FreshAgentComposer({ disabled = false, onSend }: FreshAgentComposerProps) { + return ( + <form + className="border-t border-border/60 p-3" + onSubmit={(event) => { + event.preventDefault() + const form = event.currentTarget + const input = new FormData(form).get('message') + const text = typeof input === 'string' ? input.trim() : '' + if (!text || disabled) return + onSend?.(text) + form.reset() + }} + > + <div className="flex items-end gap-2"> + <textarea + name="message" + aria-label="Chat message input" + disabled={disabled} + rows={2} + placeholder={disabled ? 'Read-only session' : 'Send a message'} + className="min-h-[52px] flex-1 resize-none rounded-md border border-border/70 bg-background px-3 py-2 text-sm outline-none" + /> + <button + type="submit" + disabled={disabled} + className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50" + > + Send + </button> + </div> + </form> + ) +} diff --git a/src/components/fresh-agent/FreshAgentDiffPanel.tsx b/src/components/fresh-agent/FreshAgentDiffPanel.tsx new file mode 100644 index 000000000..ff65b25cf --- /dev/null +++ b/src/components/fresh-agent/FreshAgentDiffPanel.tsx @@ -0,0 +1,13 @@ +export function FreshAgentDiffPanel({ diffs }: { diffs: Array<{ id: string; path?: string; title?: string }> }) { + if (diffs.length === 0) return null + return ( + <div className="rounded-lg border border-border/60 bg-background/70 p-3"> + <div className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Diffs</div> + <ul className="space-y-1 text-sm"> + {diffs.map((diff) => ( + <li key={diff.id}>{diff.title ?? diff.path ?? diff.id}</li> + ))} + </ul> + </div> + ) +} diff --git a/src/components/fresh-agent/FreshAgentItemCard.tsx b/src/components/fresh-agent/FreshAgentItemCard.tsx new file mode 100644 index 000000000..af28b85c0 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentItemCard.tsx @@ -0,0 +1,168 @@ +import type { FreshAgentTranscriptItem } from '@shared/fresh-agent-contract' + +function formatJson(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value ?? null, null, 2) + } catch { + return String(value) + } +} + +function StatusBadge({ value }: { value?: string }) { + if (!value) return null + return ( + <span className="rounded-full border border-border/70 bg-background/80 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground"> + {value} + </span> + ) +} + +export function FreshAgentItemCard({ item }: { item: FreshAgentTranscriptItem }) { + if (item.kind === 'text' || item.kind === 'thinking') { + return ( + <p className="whitespace-pre-wrap break-words"> + {item.text} + </p> + ) + } + + if (item.kind === 'reasoning') { + const summary = item.summary.length > 0 ? item.summary.join('\n') : item.text + return ( + <details className="rounded-md border border-border/60 bg-background/70 px-3 py-2"> + <summary className="cursor-pointer text-xs font-medium">Reasoning</summary> + {summary ? <p className="mt-2 whitespace-pre-wrap text-sm">{summary}</p> : null} + {item.content.length > 0 ? ( + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap text-xs text-muted-foreground">{item.content.join('\n')}</pre> + ) : null} + </details> + ) + } + + if (item.kind === 'tool_use') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="mb-1 font-medium">{item.name}</div> + <pre className="overflow-x-auto whitespace-pre-wrap break-words">{formatJson(item.input ?? {})}</pre> + </div> + ) + } + + if (item.kind === 'tool_result') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="mb-1 flex items-center gap-2 font-medium"> + Tool result + {item.isError ? <StatusBadge value="error" /> : null} + </div> + <pre className="overflow-x-auto whitespace-pre-wrap break-words">{formatJson(item.content)}</pre> + </div> + ) + } + + if (item.kind === 'command') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="mb-1 flex items-center justify-between gap-2"> + <span className="font-medium">Command</span> + <StatusBadge value={item.status} /> + </div> + {item.cwd ? <div className="mb-1 text-muted-foreground">{item.cwd}</div> : null} + <pre className="overflow-x-auto whitespace-pre-wrap break-words">$ {item.command}</pre> + {item.output ? <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-muted-foreground">{item.output}</pre> : null} + {typeof item.exitCode === 'number' ? <div className="mt-1 text-muted-foreground">exit {item.exitCode}</div> : null} + </div> + ) + } + + if (item.kind === 'file_change') { + return ( + <details className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <summary className="cursor-pointer font-medium"> + File changes <StatusBadge value={item.status} /> + </summary> + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words">{formatJson(item.changes)}</pre> + </details> + ) + } + + if (item.kind === 'mcp_tool' || item.kind === 'dynamic_tool') { + const title = item.kind === 'mcp_tool' ? `${item.server}/${item.tool}` : item.tool + return ( + <details className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <summary className="cursor-pointer font-medium"> + {title} <StatusBadge value={item.status} /> + </summary> + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words">{formatJson(item.arguments)}</pre> + {'result' in item && item.result !== undefined ? ( + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-muted-foreground">{formatJson(item.result)}</pre> + ) : null} + {'contentItems' in item && item.contentItems ? ( + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-muted-foreground">{formatJson(item.contentItems)}</pre> + ) : null} + </details> + ) + } + + if (item.kind === 'collab_agent') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="mb-1 flex items-center justify-between gap-2"> + <span className="font-medium">{item.tool}</span> + <StatusBadge value={item.status} /> + </div> + <div className="text-muted-foreground">From {item.senderThreadId}</div> + <div className="text-muted-foreground">To {item.receiverThreadIds.join(', ')}</div> + {item.prompt ? <p className="mt-2 whitespace-pre-wrap">{item.prompt}</p> : null} + </div> + ) + } + + if (item.kind === 'web_search') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="font-medium">Web search</div> + <div className="mt-1 whitespace-pre-wrap">{item.query}</div> + </div> + ) + } + + if (item.kind === 'image_view') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="font-medium">Image</div> + <div className="mt-1 break-all text-muted-foreground">{item.path}</div> + </div> + ) + } + + if (item.kind === 'image_generation') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + <div className="mb-1 flex items-center justify-between gap-2"> + <span className="font-medium">Image generation</span> + <StatusBadge value={item.displayStatus ?? item.status} /> + </div> + {item.revisedPrompt ? <p className="whitespace-pre-wrap">{item.revisedPrompt}</p> : null} + <div className="mt-1 break-all text-muted-foreground">{item.result}</div> + {item.savedPath ? <div className="mt-1 break-all text-muted-foreground">{item.savedPath}</div> : null} + </div> + ) + } + + if (item.kind === 'review_mode') { + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + {item.event === 'entered' ? 'Entered review mode' : 'Exited review mode'} + {item.review ? <span className="text-muted-foreground"> · {item.review}</span> : null} + </div> + ) + } + + return ( + <div className="rounded-md border border-border/60 bg-background/70 px-3 py-2 text-xs"> + Context compaction + </div> + ) +} diff --git a/src/components/fresh-agent/FreshAgentQuestionBanner.tsx b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx new file mode 100644 index 000000000..37eae9616 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx @@ -0,0 +1,246 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { MessageCircleQuestion } from 'lucide-react' +import { cn } from '@/lib/utils' + +type FreshAgentQuestion = { + requestId: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiSelect: boolean + }> +} + +function SingleSelectQuestion({ + question, + onSelect, + disabled, +}: { + question: FreshAgentQuestion['questions'][number] + onSelect: (answer: string) => void + disabled?: boolean +}) { + const [showOther, setShowOther] = useState(false) + const [otherText, setOtherText] = useState('') + const otherInputRef = useRef<HTMLInputElement>(null) + + useEffect(() => { + if (showOther) otherInputRef.current?.focus() + }, [showOther]) + + return ( + <div className="space-y-2"> + <p className="text-sm font-medium">{question.question}</p> + <div className="flex flex-wrap gap-2"> + {question.options.map((option) => ( + <button + key={option.label} + type="button" + onClick={() => onSelect(option.label)} + disabled={disabled} + className={cn( + 'px-3 py-1.5 text-xs rounded-md border transition-colors', + 'bg-sky-600/10 border-sky-500/30 hover:bg-sky-600/20 hover:border-sky-500/50', + 'disabled:opacity-50', + )} + aria-label={option.label} + > + <span className="font-medium">{option.label}</span> + {option.description ? ( + <span className="block text-[10px] text-muted-foreground">{option.description}</span> + ) : null} + </button> + ))} + <button + type="button" + onClick={() => setShowOther(true)} + disabled={disabled} + className={cn( + 'px-3 py-1.5 text-xs rounded-md border transition-colors', + 'bg-muted/50 border-border hover:bg-muted', + 'disabled:opacity-50', + )} + aria-label="Other" + > + Other + </button> + </div> + {showOther ? ( + <div className="flex items-center gap-2"> + <input + ref={otherInputRef} + type="text" + value={otherText} + onChange={(event) => setOtherText(event.target.value)} + placeholder="Type your answer..." + className="flex-1 rounded border bg-background px-2 py-1 text-xs" + /> + <button + type="button" + onClick={() => otherText.trim() && onSelect(otherText.trim())} + disabled={disabled || !otherText.trim()} + className={cn( + 'px-3 py-1 text-xs rounded font-medium', + 'bg-sky-600 text-white hover:bg-sky-700', + 'disabled:opacity-50', + )} + aria-label="Submit" + > + Submit + </button> + </div> + ) : null} + </div> + ) +} + +function MultiSelectQuestion({ + question, + onSelect, + disabled, +}: { + question: FreshAgentQuestion['questions'][number] + onSelect: (answer: string) => void + disabled?: boolean +}) { + const [selected, setSelected] = useState<Set<string>>(new Set()) + + const toggle = useCallback((label: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(label)) next.delete(label) + else next.add(label) + return next + }) + }, []) + + const handleSubmit = useCallback(() => { + if (selected.size > 0) onSelect(Array.from(selected).join(', ')) + }, [onSelect, selected]) + + return ( + <div className="space-y-2"> + <p className="text-sm font-medium">{question.question}</p> + <div className="flex flex-wrap gap-2"> + {question.options.map((option) => ( + <button + key={option.label} + type="button" + onClick={() => toggle(option.label)} + disabled={disabled} + className={cn( + 'px-3 py-1.5 text-xs rounded-md border transition-colors', + selected.has(option.label) + ? 'bg-sky-600/30 border-sky-500/60 ring-1 ring-sky-500/40' + : 'bg-sky-600/10 border-sky-500/30 hover:bg-sky-600/20', + 'disabled:opacity-50', + )} + aria-label={option.label} + aria-pressed={selected.has(option.label)} + > + <span className="font-medium">{option.label}</span> + {option.description ? ( + <span className="block text-[10px] text-muted-foreground">{option.description}</span> + ) : null} + </button> + ))} + </div> + <button + type="button" + onClick={handleSubmit} + disabled={disabled || selected.size === 0} + className={cn( + 'px-3 py-1 text-xs rounded font-medium', + 'bg-sky-600 text-white hover:bg-sky-700', + 'disabled:opacity-50', + )} + aria-label="Submit" + > + Submit + </button> + </div> + ) +} + +function FreshAgentQuestionBanner({ + question, + onAnswer, + disabled, + providerLabel, +}: { + question: FreshAgentQuestion + onAnswer: (answers: Record<string, string>) => void + disabled?: boolean + providerLabel: string +}) { + const [answered, setAnswered] = useState<Record<string, string>>({}) + const questions = question.questions + + const handleAnswer = useCallback((idx: number, questionText: string, answer: string) => { + if (questions.length === 1) { + onAnswer({ [questionText]: answer }) + return + } + setAnswered((prev) => ({ ...prev, [String(idx)]: answer })) + }, [onAnswer, questions.length]) + + const allAnswered = questions.length > 1 && questions.every((_, idx) => answered[String(idx)] !== undefined) + const regionLabel = `Question from ${providerLabel}` + const heading = `${providerLabel} has a question` + + return ( + <div + className="rounded-lg border border-sky-500/50 bg-sky-500/10 p-3 space-y-3" + role="region" + aria-label={regionLabel} + > + <div className="flex items-center gap-2 text-sm font-medium"> + <MessageCircleQuestion className="h-4 w-4 text-sky-500" /> + <span>{heading}</span> + </div> + + {questions.map((entry, idx) => ( + entry.multiSelect ? ( + <MultiSelectQuestion + key={`${idx}-${entry.question}`} + question={entry} + onSelect={(answer) => handleAnswer(idx, entry.question, answer)} + disabled={disabled} + /> + ) : ( + <SingleSelectQuestion + key={`${idx}-${entry.question}`} + question={entry} + onSelect={(answer) => handleAnswer(idx, entry.question, answer)} + disabled={disabled} + /> + ) + ))} + + {allAnswered ? ( + <button + type="button" + onClick={() => { + const result: Record<string, string> = {} + questions.forEach((entry, idx) => { + result[entry.question] = answered[String(idx)] + }) + onAnswer(result) + }} + disabled={disabled} + className={cn( + 'px-4 py-1.5 text-xs rounded font-medium', + 'bg-sky-600 text-white hover:bg-sky-700', + 'disabled:opacity-50', + )} + aria-label="Submit all answers" + > + Submit all answers + </button> + ) : null} + </div> + ) +} + +export default memo(FreshAgentQuestionBanner) diff --git a/src/components/fresh-agent/FreshAgentSidebar.tsx b/src/components/fresh-agent/FreshAgentSidebar.tsx new file mode 100644 index 000000000..44d9211f7 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentSidebar.tsx @@ -0,0 +1,60 @@ +export function FreshAgentSidebar({ + worktrees, + childThreads, + codexReview, + codexFork, +}: { + worktrees: Array<{ id: string; path: string; branch?: string }> + childThreads: Array<{ id: string; threadId: string; origin?: string; title?: string }> + codexReview?: { id?: string; status?: string } + codexFork?: { parentThreadId?: string } +}) { + if ( + worktrees.length === 0 + && childThreads.length === 0 + && !codexReview + && !codexFork + ) return null + return ( + <aside className="w-full max-w-xs space-y-3 border-l border-border/60 bg-muted/20 p-3"> + {codexReview ? ( + <section> + <div className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Review</div> + <ul className="space-y-1 text-sm"> + {codexReview.status ? <li>{codexReview.status}</li> : null} + {codexReview.id ? <li>{codexReview.id}</li> : null} + </ul> + </section> + ) : null} + {codexFork?.parentThreadId ? ( + <section> + <div className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Fork lineage</div> + <ul className="space-y-1 text-sm"> + <li>Parent thread</li> + <li>{codexFork.parentThreadId}</li> + </ul> + </section> + ) : null} + {worktrees.length > 0 ? ( + <section> + <div className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Worktrees</div> + <ul className="space-y-1 text-sm"> + {worktrees.map((worktree) => ( + <li key={worktree.id}>{worktree.branch ? `${worktree.branch} · ` : ''}{worktree.path}</li> + ))} + </ul> + </section> + ) : null} + {childThreads.length > 0 ? ( + <section> + <div className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Child Threads</div> + <ul className="space-y-1 text-sm"> + {childThreads.map((thread) => ( + <li key={thread.id}>{thread.title ?? thread.threadId}</li> + ))} + </ul> + </section> + ) : null} + </aside> + ) +} diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx new file mode 100644 index 000000000..44ac32cf7 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -0,0 +1,52 @@ +import type { FreshAgentTurn } from '@shared/fresh-agent-contract' +import { FreshAgentItemCard } from './FreshAgentItemCard' + +function getTurnLabel(turn: FreshAgentTurn): string { + switch (turn.role) { + case 'user': + return 'You' + case 'assistant': + return 'Assistant' + case 'system': + return 'System' + case 'tool': + return 'Tool' + default: + return 'Turn' + } +} + +export function FreshAgentTranscript({ turns }: { turns: FreshAgentTurn[] }) { + return ( + <div className="flex flex-1 flex-col gap-3 overflow-y-auto px-3 py-3" data-context="fresh-agent-transcript"> + {turns.length === 0 ? ( + <div className="rounded-lg border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground"> + No transcript available yet. + </div> + ) : turns.map((turn) => { + const isUser = turn.role === 'user' + return ( + <article + key={turn.id} + className={isUser + ? 'max-w-[92%] self-end rounded-xl bg-primary px-4 py-3 text-primary-foreground' + : 'max-w-[96%] self-start rounded-xl bg-muted px-4 py-3'} + aria-label={`${getTurnLabel(turn)} transcript turn`} + > + <div className="mb-2 flex items-center justify-between gap-2 text-[11px] uppercase tracking-[0.16em] opacity-70"> + <span>{getTurnLabel(turn)}</span> + {turn.model ? <span>{turn.model}</span> : null} + </div> + <div className="space-y-2 text-sm"> + {turn.items.length > 0 ? ( + turn.items.map((item) => <FreshAgentItemCard key={item.id} item={item} />) + ) : ( + <p className="whitespace-pre-wrap break-words">{turn.summary}</p> + )} + </div> + </article> + ) + })} + </div> + ) +} diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx new file mode 100644 index 000000000..d82660958 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -0,0 +1,708 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { nanoid } from 'nanoid' +import PermissionBanner from '@/components/agent-chat/PermissionBanner' +import type { FreshAgentPaneContent } from '@/store/paneTypes' +import { useAppDispatch, useAppSelector } from '@/store/hooks' +import { getWsClient } from '@/lib/ws-client' +import { getFreshAgentThreadSnapshot } from '@/lib/api' +import { mergePaneContent, updatePaneContent } from '@/store/panesSlice' +import { clearPendingCreateFailure } from '@/store/freshAgentSlice' +import { handleFreshAgentTransportEvent, registerFreshAgentCreate } from '@/lib/fresh-agent-ws' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' +import { getCanonicalDurableSessionId, getPreferredResumeSessionId } from '@/store/persistControl' +import { isValidClaudeSessionId } from '@/lib/claude-session-id' +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import { buildRestoreError, type RestoreErrorReason } from '@shared/session-contract' +import { FreshAgentApprovalBanner } from './FreshAgentApprovalBanner' +import FreshAgentQuestionBanner from './FreshAgentQuestionBanner' +import { FreshAgentTranscript } from './FreshAgentTranscript' +import { FreshAgentComposer } from './FreshAgentComposer' +import { FreshAgentDiffPanel } from './FreshAgentDiffPanel' +import { FreshAgentSidebar } from './FreshAgentSidebar' + +const EARLY_STATES = new Set(['creating', 'starting']) + +function isStatusRegression(current: string, next: string): boolean { + return !EARLY_STATES.has(current) && EARLY_STATES.has(next) +} + +function getStatusLabel(status: FreshAgentPaneContent['status'], restoring: boolean): string { + if (restoring) return 'Restoring' + switch (status) { + case 'connected': + return 'Connected' + case 'idle': + return 'Ready' + case 'running': + return 'Running' + case 'compacting': + return 'Compacting' + case 'creating': + case 'starting': + return 'Starting session' + case 'exited': + return 'Exited' + case 'create-failed': + return 'Create failed' + default: + return 'Starting session' + } +} + +function getCanonicalPaneResumeSessionId(pane: FreshAgentPaneContent): string | undefined { + if (pane.sessionRef?.provider === 'claude' && isValidClaudeSessionId(pane.sessionRef.sessionId)) { + return pane.sessionRef.sessionId + } + if (isValidClaudeSessionId(pane.resumeSessionId)) { + return pane.resumeSessionId + } + return undefined +} + +function getQuestionAgentLabel(paneContent: FreshAgentPaneContent, descriptorLabel?: string): string { + if (paneContent.sessionType === 'kilroy') return 'Kilroy' + switch (paneContent.provider) { + case 'claude': + return 'Claude' + case 'codex': + return 'Codex' + case 'opencode': + return 'Opencode' + default: + return descriptorLabel ?? 'Fresh Agent' + } +} + +function getRestoreErrorMessage(reason: RestoreErrorReason): string { + switch (reason) { + case 'invalid_legacy_restore_target': + return 'This session cannot be resumed because Freshell only has a legacy name, not a canonical Claude session id.' + case 'dead_live_handle': + return 'This session cannot be resumed because the live session handle is gone and no durable session id was saved.' + case 'missing_canonical_identity': + return 'This session cannot be resumed because no canonical session id was saved.' + case 'durable_artifact_missing': + return 'This session cannot be resumed because the saved session artifact is no longer available.' + case 'provider_runtime_failed': + return 'This session cannot be resumed because the provider runtime rejected the restore request.' + default: + return 'This session cannot be resumed.' + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function readCodexReview(value: unknown): { id?: string; status?: string } | undefined { + if (!isRecord(value)) return undefined + return { + id: typeof value.id === 'string' ? value.id : undefined, + status: typeof value.status === 'string' ? value.status : undefined, + } +} + +function readCodexFork(value: unknown): { parentThreadId?: string } | undefined { + if (!isRecord(value)) return undefined + return { + parentThreadId: typeof value.parentThreadId === 'string' ? value.parentThreadId : undefined, + } +} + +export function FreshAgentView({ + tabId, + paneId, + paneContent, + hidden, +}: { + tabId: string + paneId: string + paneContent: FreshAgentPaneContent + hidden?: boolean +}) { + const dispatch = useAppDispatch() + const ws = getWsClient() + const pendingCreateFailure = useAppSelector( + (state) => state.freshAgent?.pendingCreateFailures?.[paneContent.createRequestId], + ) + const claudeSession = useAppSelector((state) => { + if (paneContent.provider !== 'claude' || !paneContent.sessionId) return undefined + const sessionKey = makeFreshAgentSessionKey({ + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + }) + return state.freshAgent.sessions[sessionKey] ?? state.agentChat.sessions[paneContent.sessionId] + }) + const [snapshot, setSnapshot] = useState<FreshAgentSnapshot | null>(null) + const [loadError, setLoadError] = useState<string | null>(null) + const [snapshotRefreshNonce, setSnapshotRefreshNonce] = useState(0) + const descriptor = resolveFreshAgentType(paneContent.sessionType) + const paneContentRef = useRef(paneContent) + paneContentRef.current = paneContent + const snapshotSessionId = paneContent.sessionId + ?? (paneContent.sessionRef?.provider === paneContent.provider ? paneContent.sessionRef.sessionId : undefined) + const restoreTimeoutRef = useRef<number | null>(null) + const createSentRef = useRef(false) + const preferredResumeSessionId = getPreferredResumeSessionId(claudeSession) ?? paneContent.resumeSessionId + const hasRestoreFailure = Boolean( + paneContent.provider === 'claude' + && paneContent.sessionId + && claudeSession?.historyLoaded + && claudeSession?.restoreFailureCode + && claudeSession?.restoreFailureMessage, + ) + const isRestoring = Boolean( + paneContent.provider === 'claude' + && paneContent.sessionId + && !snapshot + && Boolean(claudeSession?.latestTurnId !== undefined || claudeSession?.lost) + && claudeSession?.historyLoaded !== true + && !hasRestoreFailure, + ) + + const sendFreshAgentMessage = useCallback((message: Record<string, unknown>) => { + const suppressed = typeof window !== 'undefined' + && window.__FRESHELL_TEST_HARNESS__?.isAgentChatNetworkEffectsSuppressed?.(paneId) === true + if (suppressed) { + window.__FRESHELL_TEST_HARNESS__?.recordSentWsMessage?.(message) + return + } + ws.send(message as never) + }, [paneId, ws]) + + const prevCreateRequestIdRef = useRef(paneContent.createRequestId) + if (prevCreateRequestIdRef.current !== paneContent.createRequestId) { + prevCreateRequestIdRef.current = paneContent.createRequestId + createSentRef.current = false + } + + const buildCreateMessage = useCallback((content: FreshAgentPaneContent) => ({ + type: 'freshAgent.create', + requestId: content.createRequestId, + sessionType: content.sessionType, + provider: content.provider, + cwd: content.initialCwd, + resumeSessionId: content.resumeSessionId, + sessionRef: content.sessionRef, + modelSelection: content.modelSelection, + model: content.model, + permissionMode: content.permissionMode, + sandbox: content.sandbox, + effort: content.effort, + plugins: content.plugins, + } as const), []) + + const triggerRecovery = useCallback(() => { + if (restoreTimeoutRef.current !== null) { + clearTimeout(restoreTimeoutRef.current) + restoreTimeoutRef.current = null + } + const nextRequestId = nanoid() + const canonicalResumeSessionId = getCanonicalDurableSessionId(claudeSession) + ?? getCanonicalPaneResumeSessionId(paneContentRef.current) + if (!canonicalResumeSessionId) { + const hadLegacyRestoreTarget = Boolean(getPreferredResumeSessionId(claudeSession) || paneContentRef.current.resumeSessionId) + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + sessionId: undefined, + resumeSessionId: undefined, + sessionRef: undefined, + restoreError: buildRestoreError(hadLegacyRestoreTarget ? 'invalid_legacy_restore_target' : 'dead_live_handle'), + createRequestId: nextRequestId, + status: 'idle', + createError: undefined, + }, + })) + return + } + + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + sessionId: undefined, + resumeSessionId: canonicalResumeSessionId, + sessionRef: { provider: 'claude', sessionId: canonicalResumeSessionId }, + restoreError: undefined, + createRequestId: nextRequestId, + status: 'creating', + createError: undefined, + }, + })) + }, [claudeSession, dispatch, paneId, tabId]) + + useEffect(() => { + if (paneContent.sessionId || hidden) return + if (paneContent.restoreError) return + if ( + paneContent.status !== 'creating' + && paneContent.status !== 'starting' + && !paneContent.sessionRef + ) return + if (createSentRef.current) return + createSentRef.current = true + registerFreshAgentCreate(dispatch, paneContent.createRequestId, { + sessionType: paneContent.sessionType, + provider: paneContent.provider, + resumeSessionId: paneContent.resumeSessionId, + sessionRef: paneContent.sessionRef, + }) + sendFreshAgentMessage(buildCreateMessage(paneContent)) + }, [ + buildCreateMessage, + dispatch, + hidden, + paneContent, + sendFreshAgentMessage, + ]) + + useEffect(() => { + if (hidden) return + if (paneContent.sessionId || !createSentRef.current) return + if (paneContent.status !== 'creating' && paneContent.status !== 'starting') return + if (typeof ws.onReconnect !== 'function') return + return ws.onReconnect(() => { + const current = paneContentRef.current + if (current.sessionId) return + if (current.status !== 'creating' && current.status !== 'starting') return + sendFreshAgentMessage(buildCreateMessage(current)) + }) + }, [ + buildCreateMessage, + hidden, + paneContent.sessionId, + paneContent.status, + sendFreshAgentMessage, + ws, + ]) + + useEffect(() => { + if (!paneContent.sessionId || hidden) return + sendFreshAgentMessage({ + type: 'freshAgent.attach', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + resumeSessionId: paneContent.resumeSessionId, + }) + }, [hidden, paneContent.provider, paneContent.resumeSessionId, paneContent.sessionId, paneContent.sessionType]) + + useEffect(() => { + if (typeof ws.onMessage !== 'function') return + const unsubscribe = ws.onMessage((message) => { + if (message.type === 'freshAgent.created' && message.requestId === paneContentRef.current.createRequestId) { + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + sessionId: message.sessionId, + sessionRef: message.sessionRef ?? paneContentRef.current.sessionRef, + resumeSessionId: paneContentRef.current.resumeSessionId + ?? (message.sessionRef?.provider === paneContentRef.current.provider + ? message.sessionRef.sessionId + : message.sessionId), + status: 'connected', + createError: undefined, + restoreError: undefined, + }, + })) + } + if (message.type === 'freshAgent.create.failed' && message.requestId === paneContentRef.current.createRequestId) { + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + status: 'create-failed', + createError: { + code: message.code, + message: message.message, + retryable: message.retryable, + }, + }, + })) + } + if ( + message.type === 'freshAgent.event' + && message.sessionId === paneContent.sessionId + && message.sessionType === paneContent.sessionType + && message.provider === paneContent.provider + ) { + handleFreshAgentTransportEvent(dispatch, { + type: 'freshAgent.event', + sessionId: message.sessionId, + sessionType: message.sessionType, + provider: message.provider, + event: (message.event ?? {}) as Record<string, unknown>, + }) + setSnapshotRefreshNonce((value) => value + 1) + } + }) + return unsubscribe + }, [dispatch, paneContent, paneContent.createRequestId, paneId, tabId, ws]) + + useEffect(() => { + if (!snapshotSessionId) return + if (paneContent.provider === 'claude' && claudeSession?.lost) return + const controller = new AbortController() + setLoadError(null) + const sessionId = snapshotSessionId + const provider = paneContent.provider + void getFreshAgentThreadSnapshot(paneContent.sessionType, provider, sessionId, { signal: controller.signal }) + .then((next) => { + const resolved = next as FreshAgentSnapshot + setSnapshot(resolved) + const fresh = paneContentRef.current + const nextStatus = (resolved.status as FreshAgentPaneContent['status']) ?? fresh.status + const nextResumeSessionId = fresh.resumeSessionId ?? sessionId + if (nextStatus === fresh.status && nextResumeSessionId === fresh.resumeSessionId) { + return + } + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...fresh, + status: nextStatus, + resumeSessionId: nextResumeSessionId, + }, + })) + }) + .catch((error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return + if (paneContent.provider === 'claude' && claudeSession) { + setLoadError(null) + return + } + setLoadError(error instanceof Error ? error.message : 'Failed to load session') + }) + return () => controller.abort() + }, [ + claudeSession?.lost, + dispatch, + paneContent, + paneContent.provider, + paneContent.resumeSessionId, + paneContent.sessionId, + paneContent.status, + paneContent.sessionType, + paneId, + snapshotSessionId, + snapshotRefreshNonce, + tabId, + ]) + + const claudeSessionStatus = claudeSession?.status + useEffect(() => { + if (paneContent.provider !== 'claude') return + if (!claudeSessionStatus || claudeSessionStatus === paneContent.status) return + if (claudeSession?.lost) return + if (isStatusRegression(paneContent.status, claudeSessionStatus)) return + dispatch(mergePaneContent({ + tabId, + paneId, + updates: { status: claudeSessionStatus }, + })) + }, [claudeSession?.lost, claudeSessionStatus, dispatch, paneContent.provider, paneContent.status, paneId, tabId]) + + useEffect(() => { + if (paneContent.provider !== 'claude') return + if (!paneContent.sessionId) return + const canonicalResumeSessionId = getCanonicalDurableSessionId(claudeSession) + const shouldUpdateResumeSessionId = Boolean( + preferredResumeSessionId && preferredResumeSessionId !== paneContent.resumeSessionId, + ) + const shouldClearRestoreError = Boolean(canonicalResumeSessionId && paneContent.restoreError) + if (!shouldUpdateResumeSessionId && !shouldClearRestoreError) return + dispatch(mergePaneContent({ + tabId, + paneId, + updates: { + ...(shouldUpdateResumeSessionId ? { resumeSessionId: preferredResumeSessionId } : {}), + ...(canonicalResumeSessionId + ? { + sessionRef: { provider: 'claude', sessionId: canonicalResumeSessionId }, + restoreError: undefined, + } + : {}), + }, + })) + }, [ + claudeSession, + dispatch, + paneContent.provider, + paneContent.resumeSessionId, + paneContent.restoreError, + paneContent.sessionId, + paneId, + preferredResumeSessionId, + tabId, + ]) + + useEffect(() => { + if (paneContent.provider !== 'claude') return + if (!paneContent.sessionId || !claudeSession?.lost) return + const shouldDeferUntilVisibleRestore = Boolean( + claudeSession.latestTurnId !== undefined && claudeSession.historyLoaded === true + ) + if (shouldDeferUntilVisibleRestore) { + const sessionIdForRecovery = paneContent.sessionId + restoreTimeoutRef.current = window.setTimeout(() => { + restoreTimeoutRef.current = null + if (paneContentRef.current.sessionId !== sessionIdForRecovery) return + if (!claudeSession?.lost) return + triggerRecovery() + }, 0) + return () => { + if (restoreTimeoutRef.current !== null) { + clearTimeout(restoreTimeoutRef.current) + restoreTimeoutRef.current = null + } + } + } + triggerRecovery() + }, [ + claudeSession?.historyLoaded, + claudeSession?.latestTurnId, + claudeSession?.lost, + paneContent.provider, + paneContent.sessionId, + triggerRecovery, + ]) + + const content = useMemo(() => { + const turns = snapshot?.turns ?? [] + const pendingApprovals = snapshot?.pendingApprovals ?? [] + const pendingQuestions = snapshot?.pendingQuestions ?? [] + const worktrees = snapshot?.worktrees ?? [] + const childThreads = snapshot?.childThreads ?? [] + const diffs = snapshot?.diffs ?? [] + const codexReview = readCodexReview(snapshot?.extensions?.codex?.review) + const codexFork = readCodexFork(snapshot?.extensions?.codex?.fork) + const effectiveStatus = paneContent.provider === 'claude' + ? (claudeSessionStatus ?? paneContent.status) + : paneContent.status + const canSend = snapshot?.capabilities?.send === true || ( + paneContent.provider === 'claude' + && Boolean(paneContent.sessionId) + && !isRestoring + && !hasRestoreFailure + && !['creating', 'starting', 'create-failed', 'exited'].includes(effectiveStatus) + ) + const canInterrupt = snapshot?.capabilities?.interrupt === true || ( + paneContent.provider === 'claude' + && Boolean(paneContent.sessionId) + && ['connected', 'running', 'idle', 'compacting'].includes(effectiveStatus) + ) + const canFork = snapshot?.capabilities?.fork === true + const totalTokens = snapshot?.tokenUsage?.totalTokens + const statusLabel = getStatusLabel(effectiveStatus, isRestoring) + const questionAgentLabel = getQuestionAgentLabel(paneContent, descriptor?.label) + const summaryText = isRestoring + ? 'Restoring session' + : snapshot?.summary || paneContent.sessionId || statusLabel + const visibleRestoreFailure = paneContent.provider === 'claude' + ? claudeSession?.restoreFailureMessage + : null + const visiblePaneRestoreFailure = visibleRestoreFailure + ? null + : (paneContent.restoreError ? getRestoreErrorMessage(paneContent.restoreError.reason) : null) + const visibleLoadError = visibleRestoreFailure || visiblePaneRestoreFailure || isRestoring ? null : loadError + + return ( + <div className="flex h-full min-h-0 flex-col" data-context="fresh-agent" data-session-id={paneContent.sessionId}> + <div className="border-b border-border/60 px-3 py-3"> + <div className="flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-semibold">{descriptor?.label ?? 'Fresh Agent'}</div> + <div className="text-xs text-muted-foreground">{summaryText}</div> + </div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>{statusLabel}</span> + {typeof totalTokens === 'number' ? <span>{totalTokens} tokens</span> : null} + <button + type="button" + className="rounded border border-border/70 px-2 py-1 disabled:opacity-50" + disabled={!canInterrupt || !paneContent.sessionId} + onClick={() => { + if (!paneContent.sessionId || !canInterrupt) return + sendFreshAgentMessage({ + type: 'freshAgent.interrupt', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + }) + }} + > + Interrupt + </button> + <button + type="button" + className="rounded border border-border/70 px-2 py-1 disabled:opacity-50" + disabled={!canFork || !paneContent.sessionId} + onClick={() => { + if (!paneContent.sessionId || !canFork) return + sendFreshAgentMessage({ + type: 'freshAgent.fork', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + }) + }} + > + Fork + </button> + </div> + </div> + </div> + <div className="flex min-h-0 flex-1"> + <div className="flex min-h-0 flex-1 flex-col"> + <div className="space-y-2 px-3 pt-3"> + {pendingCreateFailure || paneContent.createError ? ( + <div className="flex items-center justify-between gap-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm"> + <FreshAgentApprovalBanner text={(pendingCreateFailure ?? paneContent.createError)?.message ?? 'Create failed'} /> + {(pendingCreateFailure ?? paneContent.createError)?.retryable ? ( + <button + type="button" + className="rounded border border-border/70 px-2 py-1" + onClick={() => { + const nextRequestId = nanoid() + dispatch(updatePaneContent({ + tabId, + paneId, + content: { + ...paneContentRef.current, + sessionId: undefined, + createRequestId: nextRequestId, + status: 'creating', + createError: undefined, + }, + })) + }} + > + Retry + </button> + ) : null} + </div> + ) : null} + {visibleRestoreFailure ? <FreshAgentApprovalBanner text={visibleRestoreFailure} /> : null} + {visiblePaneRestoreFailure ? <FreshAgentApprovalBanner text={visiblePaneRestoreFailure} /> : null} + {visibleLoadError ? <FreshAgentApprovalBanner text={visibleLoadError} /> : null} + {pendingApprovals.map((approval) => ( + <PermissionBanner + key={String(approval.requestId)} + permission={{ + requestId: String(approval.requestId), + subtype: 'can_use_tool', + tool: approval.toolName + ? { name: approval.toolName, input: approval.input } + : undefined, + }} + onAllow={() => { + if (!paneContent.sessionId) return + sendFreshAgentMessage({ + type: 'freshAgent.approval.respond', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + requestId: approval.requestId, + decision: { behavior: 'allow', updatedInput: {} }, + }) + }} + onDeny={() => { + if (!paneContent.sessionId) return + sendFreshAgentMessage({ + type: 'freshAgent.approval.respond', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + requestId: approval.requestId, + decision: { behavior: 'deny', message: 'Denied by user', interrupt: false }, + }) + }} + disabled={!paneContent.sessionId} + /> + ))} + {pendingQuestions.map((question) => ( + <FreshAgentQuestionBanner + key={String(question.requestId)} + question={{ + requestId: String(question.requestId), + questions: (question.questions ?? []).map((entry) => ({ + question: entry.question, + header: entry.header ?? 'Question', + options: entry.options ?? [], + multiSelect: entry.multiSelect === true, + })), + }} + providerLabel={questionAgentLabel} + onAnswer={(answers) => { + if (!paneContent.sessionId) return + sendFreshAgentMessage({ + type: 'freshAgent.question.respond', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + requestId: question.requestId, + answers, + }) + }} + disabled={!paneContent.sessionId} + /> + ))} + <FreshAgentDiffPanel diffs={diffs} /> + </div> + <FreshAgentTranscript turns={turns} /> + <FreshAgentComposer + disabled={!canSend || !paneContent.sessionId} + onSend={(text) => { + if (!paneContent.sessionId || !canSend) return + sendFreshAgentMessage({ + type: 'freshAgent.send', + sessionId: paneContent.sessionId, + sessionType: paneContent.sessionType, + provider: paneContent.provider, + text, + }) + }} + /> + </div> + <FreshAgentSidebar + worktrees={worktrees} + childThreads={childThreads} + codexReview={codexReview} + codexFork={codexFork} + /> + </div> + </div> + ) + }, [ + claudeSession?.restoreFailureMessage, + claudeSessionStatus, + descriptor?.label, + hasRestoreFailure, + isRestoring, + loadError, + paneContent, + pendingCreateFailure, + snapshot, + ]) + + useEffect(() => { + if (!pendingCreateFailure) return + return () => { + dispatch(clearPendingCreateFailure({ requestId: paneContent.createRequestId })) + } + }, [dispatch, paneContent.createRequestId, pendingCreateFailure]) + + return content +} + +export default FreshAgentView diff --git a/src/components/icons/PaneIcon.tsx b/src/components/icons/PaneIcon.tsx index 2ecd9755e..9c56b6786 100644 --- a/src/components/icons/PaneIcon.tsx +++ b/src/components/icons/PaneIcon.tsx @@ -2,6 +2,7 @@ import { Terminal, Globe, FileText, LayoutGrid } from 'lucide-react' import { ProviderIcon } from '@/components/icons/provider-icons' import { isNonShellMode } from '@/lib/coding-cli-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import type { PaneContent } from '@/store/paneTypes' interface PaneIconProps { @@ -34,6 +35,15 @@ export default function PaneIcon({ content, className }: PaneIconProps) { return <LayoutGrid className={className} /> } + if (content.kind === 'fresh-agent') { + const config = resolveFreshAgentType(content.sessionType) + if (config) { + const Icon = config.icon + return <Icon className={className} /> + } + return <LayoutGrid className={className} /> + } + if (content.kind === 'extension') { // V1: LayoutGrid fallback. Future: load SVG from iconUrl return <LayoutGrid className={className} /> diff --git a/src/components/markdown/LazyMarkdown.tsx b/src/components/markdown/LazyMarkdown.tsx index 990443796..c43327e3c 100644 --- a/src/components/markdown/LazyMarkdown.tsx +++ b/src/components/markdown/LazyMarkdown.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense, type ReactNode } from 'react' +import { withChunkErrorRecovery } from '@/lib/import-retry' const MarkdownRenderer = lazy(() => - import('./MarkdownRenderer').then((module) => ({ default: module.MarkdownRenderer })) + withChunkErrorRecovery(import('./MarkdownRenderer')).then((module) => ({ default: module.MarkdownRenderer })) ) type LazyMarkdownProps = { diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index a6c35edf2..99ea0e524 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -8,15 +8,18 @@ import PaneDivider from './PaneDivider' import TerminalView from '../TerminalView' import BrowserPane from './BrowserPane' import AgentChatView from '../agent-chat/AgentChatView' +import FreshAgentView from '../fresh-agent/FreshAgentView' import ExtensionPane from './ExtensionPane' import PanePicker, { type PanePickerType } from './PanePicker' import DirectoryPicker from './DirectoryPicker' import { getProviderLabel, isCodingCliProviderName } from '@/lib/coding-cli-utils' import { isAgentChatProviderName, getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import { clearDraft } from '@/lib/draft-store' import { getTerminalActions } from '@/lib/pane-action-registry' import { buildPaneRefreshTarget } from '@/lib/pane-utils' import { cn } from '@/lib/utils' +import { withChunkErrorRecovery } from '@/lib/import-retry' import { getWsClient } from '@/lib/ws-client' import { api } from '@/lib/api' import { resolvePaneActivity } from '@/lib/pane-activity' @@ -32,10 +35,15 @@ import { nanoid } from 'nanoid' import { ContextIds } from '@/components/context-menu/context-menu-constants' import type { CodingCliProviderName } from '@/lib/coding-cli-types' import type { ChatSessionState, PendingAgentCreate } from '@/store/agentChatTypes' +import type { FreshAgentPendingCreate, FreshAgentSessionState } from '@/store/freshAgentTypes' import type { AgentChatPaneContent } from '@/store/paneTypes' import { normalizeAgentChatEffortOverride, normalizeAgentChatModelSelection } from '@/store/paneTypes' import { clearPaneAttention, clearTabAttention } from '@/store/turnCompletionSlice' import { clearPendingCreate, removeSession } from '@/store/agentChatSlice' +import { + clearPendingCreate as clearFreshAgentPendingCreate, + removeSession as removeFreshAgentSession, +} from '@/store/freshAgentSlice' import { cancelCreate } from '@/lib/sdk-message-handler' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' import type { TerminalMetaRecord } from '@/store/terminalMetaSlice' @@ -52,13 +60,15 @@ const EMPTY_PANE_TITLES: Record<string, string> = {} const EMPTY_TERMINAL_META_BY_ID: Record<string, TerminalMetaRecord> = {} const EMPTY_PROJECTS: ProjectGroup[] = [] const EMPTY_AGENT_CHAT_SESSIONS: Record<string, ChatSessionState> = {} +const EMPTY_FRESH_AGENT_SESSIONS: Record<string, FreshAgentSessionState> = {} const EMPTY_CODEX_ACTIVITY_BY_ID = {} const EMPTY_OPENCODE_ACTIVITY_BY_ID = {} const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record<string, PaneRuntimeActivityRecord> = {} const EMPTY_ATTENTION_BY_PANE: Record<string, boolean> = {} const EMPTY_PENDING_CREATES: Record<string, PendingAgentCreate> = {} +const EMPTY_FRESH_AGENT_PENDING_CREATES: Record<string, FreshAgentPendingCreate> = {} const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] -const EditorPane = lazy(() => import('./EditorPane')) +const EditorPane = lazy(() => withChunkErrorRecovery(import('./EditorPane'))) interface PaneContainerProps { tabId: string @@ -166,6 +176,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp ) const indexedProjects = useAppSelector((s) => s.sessions?.projects ?? EMPTY_PROJECTS) const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS) const codexActivityByTerminalId = useAppSelector( (s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID ) @@ -188,8 +199,9 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const containerRef = useRef<HTMLDivElement>(null) const ws = useMemo(() => getWsClient(), []) const snapThreshold = useAppSelector((s) => s.settings?.settings?.panes?.snapThreshold ?? 2) - const sdkPendingCreates = useAppSelector( - (s) => s.agentChat?.pendingCreates ?? EMPTY_PENDING_CREATES + const sdkPendingCreates = useAppSelector((s) => s.agentChat?.pendingCreates ?? EMPTY_PENDING_CREATES) + const freshAgentPendingCreates = useAppSelector( + (s) => s.freshAgent?.pendingCreates ?? EMPTY_FRESH_AGENT_PENDING_CREATES ) // Drag state for snapping: track the original size and accumulated delta @@ -289,6 +301,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp // No sessionId yet — sdk.created hasn't arrived. Mark the createRequestId as // cancelled so the message handler will kill the orphan when it does arrive. cancelCreate(content.createRequestId) + ws.cancelCreate(content.createRequestId) } // Clean up Redux state for orphaned pending creates if (!content.sessionId && pendingSessionId) { @@ -296,10 +309,35 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp dispatch(clearPendingCreate({ requestId: content.createRequestId })) } } + if (content.kind === 'fresh-agent') { + clearDraft(paneId) + const pendingCreate = freshAgentPendingCreates[content.createRequestId] + const pendingSessionId = pendingCreate?.sessionId + const sessionId = content.sessionId || pendingSessionId + if (sessionId) { + ws.send({ + type: 'freshAgent.kill', + sessionId, + sessionType: content.sessionType, + provider: content.provider, + }) + } else { + cancelCreate(content.createRequestId) + ws.cancelCreate(content.createRequestId) + } + if (!content.sessionId && pendingSessionId) { + dispatch(removeFreshAgentSession({ + sessionId: pendingSessionId, + sessionType: content.sessionType, + provider: content.provider, + })) + dispatch(clearFreshAgentPendingCreate({ requestId: content.createRequestId })) + } + } // Extension panes: V1 leaves server extensions running until freshell shutdown. // Future: stop singleton server when its last pane closes. dispatch(closePaneWithCleanup({ tabId, paneId })) - }, [dispatch, tabId, ws, sdkPendingCreates]) + }, [dispatch, freshAgentPendingCreates, sdkPendingCreates, tabId, ws]) const handleFocus = useCallback((paneId: string) => { if (attentionDismiss === 'click' && attentionByPane[paneId]) { @@ -377,7 +415,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const paneTitle = getPaneDisplayTitle(node.content, explicitTitle, extensionEntries) const paneStatus = node.content.kind === 'terminal' ? node.content.status - : node.content.kind === 'agent-chat' + : (node.content.kind === 'agent-chat' || node.content.kind === 'fresh-agent') ? (node.content.status === 'exited' ? 'exited' : 'running') : 'running' const isRenaming = renamingPaneId === node.id @@ -413,12 +451,28 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp provider: paneProvider, initialCwd: paneInitialCwd, }) - : node.content.kind === 'agent-chat' - ? resolveFreshClaudeRuntimeMeta( - indexedProjects, - node.content, - node.content.sessionId ? agentChatSessions[node.content.sessionId] : undefined, + : (node.content.kind === 'agent-chat' || node.content.kind === 'fresh-agent') + ? ( + node.content.kind === 'agent-chat' + || (node.content.kind === 'fresh-agent' && node.content.provider === 'claude') ) + ? resolveFreshClaudeRuntimeMeta( + indexedProjects, + node.content.kind === 'fresh-agent' + ? { + ...node.content, + kind: 'agent-chat', + provider: 'freshclaude', + effort: ( + node.content.effort === 'none' + || node.content.effort === 'minimal' + || node.content.effort === 'xhigh' + ) ? undefined : node.content.effort, + } + : node.content, + node.content.sessionId ? agentChatSessions[node.content.sessionId] : undefined, + ) + : undefined : undefined const paneMetaLabel = paneRuntimeMeta @@ -437,6 +491,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp opencodeActivityByTerminalId, paneRuntimeActivityByPaneId, agentChatSessions, + freshAgentSessions, }).isBusy const needsAttention = tabAttentionStyle !== 'none' && !!attentionByPane[node.id] @@ -522,7 +577,10 @@ function PickerWrapper({ const dispatch = useAppDispatch() const settings = useAppSelector((s) => s.settings?.settings) const agentChatSettings = useAppSelector( - (s) => s.settings?.settings?.agentChat ?? s.settings?.serverSettings?.agentChat + (s) => s.settings?.settings?.freshAgent + ?? s.settings?.settings?.agentChat + ?? s.settings?.serverSettings?.freshAgent + ?? s.settings?.serverSettings?.agentChat ) const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES) const paneLayout = useAppSelector((s) => s.panes.layouts[tabId]) @@ -545,18 +603,33 @@ function PickerWrapper({ } } - if (isAgentChatProviderName(type)) { - const providerConfig = getAgentChatProviderConfig(type)! + const freshAgentType = resolveFreshAgentType(type) + if (freshAgentType) { + const providerConfig = freshAgentType.runtimeProvider === 'claude' && isAgentChatProviderName(type) + ? getAgentChatProviderConfig(type) + : undefined const providerSettings = agentChatSettings?.providers?.[type] return { - kind: 'agent-chat', - provider: type, + kind: 'fresh-agent', + sessionType: freshAgentType.sessionType, + provider: freshAgentType.runtimeProvider, createRequestId: nanoid(), status: 'creating', modelSelection: normalizeAgentChatModelSelection(providerSettings?.modelSelection), - permissionMode: providerSettings?.defaultPermissionMode ?? providerConfig.defaultPermissionMode, - effort: normalizeAgentChatEffortOverride(providerSettings?.effort), - plugins: agentChatSettings?.defaultPlugins, + model: freshAgentType.runtimeProvider === 'codex' + ? settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.model ?? freshAgentType.defaultModel + : freshAgentType.defaultModel, + permissionMode: providerSettings?.defaultPermissionMode + ?? (freshAgentType.runtimeProvider === 'codex' + ? settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.permissionMode + : undefined) + ?? providerConfig?.defaultPermissionMode + ?? freshAgentType.defaultPermissionMode, + sandbox: freshAgentType.runtimeProvider === 'codex' + ? settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.sandbox + : undefined, + effort: normalizeAgentChatEffortOverride(providerSettings?.effort) ?? freshAgentType.defaultEffort, + plugins: freshAgentType.runtimeProvider === 'claude' ? agentChatSettings?.defaultPlugins : undefined, ...(cwd ? { initialCwd: cwd } : {}), } } @@ -624,10 +697,10 @@ function PickerWrapper({ default: throw new Error(`Unsupported pane type: ${String(type)}`) } - }, [agentChatSettings, extensionEntries]) + }, [agentChatSettings, extensionEntries, settings?.codingCli?.providers]) const handleSelect = useCallback((type: PanePickerType) => { - if (isAgentChatProviderName(type)) { + if (resolveFreshAgentType(type)) { setStep({ step: 'directory', providerType: type }) return } @@ -753,7 +826,25 @@ function renderContent( if (content.kind === 'agent-chat') { return ( <ErrorBoundary key={paneId} label="Chat"> - <AgentChatView tabId={tabId} paneId={paneId} paneContent={content} hidden={hidden} /> + <AgentChatView + tabId={tabId} + paneId={paneId} + paneContent={content} + hidden={hidden} + /> + </ErrorBoundary> + ) + } + + if (content.kind === 'fresh-agent') { + return ( + <ErrorBoundary key={paneId} label="Fresh Agent"> + <FreshAgentView + tabId={tabId} + paneId={paneId} + paneContent={content} + hidden={hidden} + /> </ErrorBoundary> ) } diff --git a/src/components/panes/PanePicker.tsx b/src/components/panes/PanePicker.tsx index b4abb5fd8..07a311a67 100644 --- a/src/components/panes/PanePicker.tsx +++ b/src/components/panes/PanePicker.tsx @@ -6,12 +6,14 @@ import { useAppSelector } from '@/store/hooks' import { ContextIds } from '@/components/context-menu/context-menu-constants' import { getCliProviderConfigs, type CodingCliProviderConfig } from '@/lib/coding-cli-utils' import { getVisibleAgentChatConfigs, type AgentChatProviderName } from '@/lib/agent-chat-utils' +import { FRESH_AGENT_REGISTRY } from '@/lib/fresh-agent-registry' import { ProviderIcon } from '@/components/icons/provider-icons' import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' import type { CodingCliProviderName } from '@/lib/coding-cli-types' import type { ClientExtensionEntry } from '@shared/extension-types' +import type { FreshAgentSessionType } from '@shared/fresh-agent' -export type PanePickerType = 'shell' | 'cmd' | 'powershell' | 'wsl' | 'browser' | 'editor' | AgentChatProviderName | CodingCliProviderName | `ext:${string}` +export type PanePickerType = 'shell' | 'cmd' | 'powershell' | 'wsl' | 'browser' | 'editor' | AgentChatProviderName | FreshAgentSessionType | CodingCliProviderName | `ext:${string}` type IconComponent = ComponentType<{ className?: string } & SVGProps<SVGSVGElement>> @@ -113,7 +115,7 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane // Agent chat options: only show if underlying CLI is available, enabled, and not hidden by feature flag const visibleAgentChatConfigs = getVisibleAgentChatConfigs(featureFlags) - const allAgentChatOptions: PickerOption[] = visibleAgentChatConfigs + const agentChatOptions: PickerOption[] = visibleAgentChatConfigs .filter((config) => availableClis[config.codingCliProvider] && enabledProviders.includes(config.codingCliProvider) && !disabledExtensions.includes(config.codingCliProvider)) .map((config) => ({ type: config.name as PanePickerType, @@ -122,9 +124,22 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane shortcut: config.pickerShortcut, afterCli: config.pickerAfterCli, })) + const otherFreshAgentOptions: PickerOption[] = FRESH_AGENT_REGISTRY + .filter((entry) => !visibleAgentChatConfigs.some((config) => config.name === entry.sessionType)) + .filter((entry) => !entry.disabled) + .filter((entry) => !entry.hidden || featureFlags[entry.featureFlag ?? entry.sessionType] === true) + .filter((entry) => availableClis[entry.runtimeProvider] && enabledProviders.includes(entry.runtimeProvider) && !disabledExtensions.includes(entry.runtimeProvider)) + .map((entry) => ({ + type: entry.sessionType as PanePickerType, + label: entry.label, + icon: entry.icon, + shortcut: entry.pickerShortcut, + afterCli: entry.pickerAfterCli, + })) - const agentChatBefore = allAgentChatOptions.filter((o) => !o.afterCli) - const agentChatAfter = allAgentChatOptions.filter((o) => o.afterCli) + const allFreshAgentOptions = [...agentChatOptions, ...otherFreshAgentOptions] + const agentChatBefore = allFreshAgentOptions.filter((o) => !o.afterCli) + const agentChatAfter = allFreshAgentOptions.filter((o) => o.afterCli) // Extension options from the registry (exclude CLI extensions and disabled extensions) const extensionOptions: PickerOption[] = extensionEntries diff --git a/src/components/settings/SafetySettings.tsx b/src/components/settings/SafetySettings.tsx index f75763115..4c49aa08b 100644 --- a/src/components/settings/SafetySettings.tsx +++ b/src/components/settings/SafetySettings.tsx @@ -114,8 +114,10 @@ export default function SafetySettings({ deviceAliases: {} as Record<string, string>, dismissedDeviceIds: [] as string[], localOpen: [], + sameDeviceOpen: [], remoteOpen: [], closed: [], + devices: [], } const [defaultCwdInput, setDefaultCwdInput] = useState(settings.defaultCwd ?? '') @@ -440,8 +442,10 @@ export default function SafetySettings({ deviceAliases: tabRegistry.deviceAliases, dismissedDeviceIds: tabRegistry.dismissedDeviceIds, localOpen: tabRegistry.localOpen, + sameDeviceOpen: tabRegistry.sameDeviceOpen, remoteOpen: tabRegistry.remoteOpen, closed: tabRegistry.closed, + devices: tabRegistry.devices, }) }, [tabRegistry]) diff --git a/src/components/settings/WorkspaceSettings.tsx b/src/components/settings/WorkspaceSettings.tsx index afc1edf61..3ae1e0abe 100644 --- a/src/components/settings/WorkspaceSettings.tsx +++ b/src/components/settings/WorkspaceSettings.tsx @@ -211,6 +211,15 @@ export default function WorkspaceSettings({ /> </SettingsRow> + <SettingsRow label="Multi-row tabs" description="Show tabs in multiple rows instead of a single scrollable row."> + <Toggle + checked={settings.panes?.multirowTabs ?? false} + onChange={(checked) => { + applyLocalSetting({ panes: { multirowTabs: checked } }) + }} + /> + </SettingsRow> + <SettingsRow label="Tab completion indicator"> <SegmentedControl value={settings.panes?.tabAttentionStyle ?? 'highlight'} @@ -253,28 +262,37 @@ export default function WorkspaceSettings({ </SettingsRow> </SettingsSection> - <SettingsSection title="Agent chat" description="Display settings for agent chat panes"> + <SettingsSection title="Fresh agent" description="Display settings for fresh-agent panes"> <SettingsRow label="Show thinking"> <Toggle - checked={settings.agentChat?.showThinking ?? false} + checked={settings.freshAgent?.showThinking ?? settings.agentChat?.showThinking ?? false} onChange={(checked) => { - applyLocalSetting({ agentChat: { showThinking: checked } }) + applyLocalSetting({ + freshAgent: { showThinking: checked }, + agentChat: { showThinking: checked }, + }) }} /> </SettingsRow> <SettingsRow label="Show tools"> <Toggle - checked={settings.agentChat?.showTools ?? false} + checked={settings.freshAgent?.showTools ?? settings.agentChat?.showTools ?? false} onChange={(checked) => { - applyLocalSetting({ agentChat: { showTools: checked } }) + applyLocalSetting({ + freshAgent: { showTools: checked }, + agentChat: { showTools: checked }, + }) }} /> </SettingsRow> <SettingsRow label="Show timecodes & model"> <Toggle - checked={settings.agentChat?.showTimecodes ?? false} + checked={settings.freshAgent?.showTimecodes ?? settings.agentChat?.showTimecodes ?? false} onChange={(checked) => { - applyLocalSetting({ agentChat: { showTimecodes: checked } }) + applyLocalSetting({ + freshAgent: { showTimecodes: checked }, + agentChat: { showTimecodes: checked }, + }) }} /> </SettingsRow> diff --git a/src/components/terminal-view-utils.ts b/src/components/terminal-view-utils.ts index ad96f028e..8abba7525 100644 --- a/src/components/terminal-view-utils.ts +++ b/src/components/terminal-view-utils.ts @@ -8,16 +8,19 @@ export function getResumeSessionIdFromRef(ref: TerminalContentRef): string | und export function getCreateSessionStateFromRef(ref: TerminalContentRef): { sessionRef?: TerminalPaneContent['sessionRef'] + codexDurability?: TerminalPaneContent['codexDurability'] liveTerminal?: { terminalId: string serverInstanceId: string } } { const sessionRef = ref.current?.sessionRef + const codexDurability = ref.current?.codexDurability const terminalId = ref.current?.terminalId const serverInstanceId = ref.current?.serverInstanceId return { ...(sessionRef ? { sessionRef } : {}), + ...(!sessionRef && codexDurability ? { codexDurability } : {}), ...(terminalId && serverInstanceId ? { liveTerminal: { diff --git a/src/components/ui/error-boundary.tsx b/src/components/ui/error-boundary.tsx index e729ced89..c1f6bb903 100644 --- a/src/components/ui/error-boundary.tsx +++ b/src/components/ui/error-boundary.tsx @@ -1,4 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' +import { isChunkLoadError, shouldReload } from '@/lib/import-retry' type ErrorBoundaryProps = { children: ReactNode @@ -30,6 +31,13 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt } private handleReset = () => { + const err = this.state.error + if (err != null && isChunkLoadError(err)) { + if (shouldReload()) { + window.location.reload() + return + } + } this.setState({ hasError: false, error: null }) } diff --git a/src/hooks/useTabBarScroll.ts b/src/hooks/useTabBarScroll.ts index a0e1a2357..57dc7ac11 100644 --- a/src/hooks/useTabBarScroll.ts +++ b/src/hooks/useTabBarScroll.ts @@ -22,7 +22,7 @@ interface TabBarScrollResult extends TabBarScrollState { const SCROLL_THRESHOLD = 2 // px tolerance for scroll boundary detection const HOLD_SCROLL_SPEED = 4 // px per frame (~240px/s at 60fps) -export function useTabBarScroll(activeTabId: string | null, tabCount: number): TabBarScrollResult { +export function useTabBarScroll(activeTabId: string | null, tabCount: number, disabled = false): TabBarScrollResult { const nodeRef = useRef<HTMLDivElement | null>(null) const cleanupRef = useRef<(() => void) | null>(null) const holdRafRef = useRef<number | null>(null) @@ -34,15 +34,20 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T const updateOverflow = useCallback((el: HTMLDivElement | null) => { if (!el) { - setOverflow({ canScrollLeft: false, canScrollRight: false }) + setOverflow(prev => + (prev.canScrollLeft === false && prev.canScrollRight === false) ? prev : { canScrollLeft: false, canScrollRight: false } + ) return } const { scrollLeft, scrollWidth, clientWidth } = el - setOverflow({ - canScrollLeft: scrollLeft > SCROLL_THRESHOLD, - canScrollRight: scrollLeft + clientWidth < scrollWidth - SCROLL_THRESHOLD, - }) + const canScrollLeft = scrollLeft > SCROLL_THRESHOLD + const canScrollRight = scrollLeft + clientWidth < scrollWidth - SCROLL_THRESHOLD + setOverflow(prev => + (prev.canScrollLeft === canScrollLeft && prev.canScrollRight === canScrollRight) + ? prev + : { canScrollLeft, canScrollRight } + ) }, []) const callbackRef = useCallback((node: HTMLDivElement | null) => { @@ -54,7 +59,7 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T nodeRef.current = node - if (!node) { + if (!node || disabled) { updateOverflow(null) return } @@ -83,7 +88,7 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T // Initial overflow check updateOverflow(node) - }, [updateOverflow]) + }, [updateOverflow, disabled]) // Clean up on unmount useEffect(() => { diff --git a/src/lib/agent-chat-types.ts b/src/lib/agent-chat-types.ts index 9da4adf8e..e89aa2f9f 100644 --- a/src/lib/agent-chat-types.ts +++ b/src/lib/agent-chat-types.ts @@ -1,7 +1,8 @@ import type { CodingCliProviderName } from '@/lib/coding-cli-types' import type { AgentChatModelSelection } from '@shared/agent-chat-capabilities' +import type { FreshAgentSessionType } from '@shared/fresh-agent' -export type AgentChatProviderName = 'freshclaude' | 'kilroy' +export type AgentChatProviderName = Extract<FreshAgentSessionType, 'freshclaude' | 'kilroy'> export type AgentChatProviderSettings = { modelSelection?: AgentChatModelSelection diff --git a/src/lib/agent-chat-utils.ts b/src/lib/agent-chat-utils.ts index 957c1973b..183e3a174 100644 --- a/src/lib/agent-chat-utils.ts +++ b/src/lib/agent-chat-utils.ts @@ -1,5 +1,5 @@ import type { AgentChatProviderName, AgentChatProviderConfig } from './agent-chat-types' -import { FreshclaudeIcon, KilroyIcon } from '@/components/icons/provider-icons' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' export type { AgentChatProviderName, AgentChatProviderConfig } @@ -11,40 +11,42 @@ export const AGENT_CHAT_PROVIDERS: AgentChatProviderName[] = [ export const AGENT_CHAT_PROVIDER_CONFIGS: AgentChatProviderConfig[] = [ { name: 'freshclaude', - label: 'Freshclaude', - codingCliProvider: 'claude', - icon: FreshclaudeIcon, - providerDefaultModelId: 'opus', - defaultPermissionMode: 'bypassPermissions', - settingsVisibility: { - model: true, - permissionMode: true, - effort: true, - thinking: true, - tools: true, - timecodes: true, - }, - pickerShortcut: 'A', + ...(() => { + const entry = resolveFreshAgentType('freshclaude') + if (!entry) { + throw new Error('Missing fresh-agent registry entry for freshclaude') + } + return { + label: entry.label, + codingCliProvider: entry.runtimeProvider, + icon: entry.icon, + providerDefaultModelId: 'opus', + defaultPermissionMode: entry.defaultPermissionMode, + settingsVisibility: entry.settingsVisibility, + pickerShortcut: entry.pickerShortcut, + } + })(), }, { name: 'kilroy', - label: 'Kilroy', - codingCliProvider: 'claude', - icon: KilroyIcon, - providerDefaultModelId: 'opus', - defaultPermissionMode: 'bypassPermissions', - settingsVisibility: { - model: true, - permissionMode: true, - effort: true, - thinking: true, - tools: true, - timecodes: true, - }, - pickerShortcut: 'K', - pickerAfterCli: true, - hidden: true, - featureFlag: 'kilroy', + ...(() => { + const entry = resolveFreshAgentType('kilroy') + if (!entry) { + throw new Error('Missing fresh-agent registry entry for kilroy') + } + return { + label: entry.label, + codingCliProvider: entry.runtimeProvider, + icon: entry.icon, + providerDefaultModelId: 'opus', + defaultPermissionMode: entry.defaultPermissionMode, + settingsVisibility: entry.settingsVisibility, + pickerShortcut: entry.pickerShortcut, + pickerAfterCli: entry.pickerAfterCli, + hidden: entry.hidden, + featureFlag: entry.featureFlag, + } + })(), }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index c8daa563a..12911cea8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,10 @@ import type { CodingCliProviderName } from './coding-cli-types' +import { + FreshAgentSnapshotSchema, + FreshAgentTurnBodySchema, + FreshAgentTurnPageSchema, +} from '@shared/fresh-agent-contract' +import { FreshAgentApiContractError } from '@/lib/fresh-agent-api-error' import { getClientPerfConfig, isClientPerfLoggingEnabled, logClientPerf } from '@/lib/perf-logger' import { getAuthToken } from '@/lib/auth' import { sanitizeSessionLocators } from '@/lib/session-utils' @@ -296,6 +302,81 @@ export async function getAgentTurnBody( ) } +export async function getFreshAgentThreadSnapshot( + sessionType: string, + provider: string, + threadId: string, + query: { revision?: number; signal?: AbortSignal } = {}, + options: ApiRequestOptions = {}, +): Promise<any> { + const signal = query.signal ?? options.signal + const data = await api.get( + `/api/fresh-agent/threads/${encodeURIComponent(sessionType)}/${encodeURIComponent(provider)}/${encodeURIComponent(threadId)}${buildQueryString([ + ['revision', query.revision], + ])}`, + { ...options, signal }, + ) + const parsed = FreshAgentSnapshotSchema.safeParse(data) + if (!parsed.success) { + throw new FreshAgentApiContractError('Fresh-agent snapshot response did not match the shared contract.', parsed.error.issues) + } + return parsed.data +} + +export async function getFreshAgentTurnPage( + sessionType: string, + provider: string, + threadId: string, + query: { + cursor?: string + priority?: string + revision: number + limit?: number + includeBodies?: boolean + signal?: AbortSignal + }, + options: ApiRequestOptions = {}, +): Promise<any> { + const signal = query.signal ?? options.signal + const data = await api.get( + `/api/fresh-agent/threads/${encodeURIComponent(sessionType)}/${encodeURIComponent(provider)}/${encodeURIComponent(threadId)}/turns${buildQueryString([ + ['revision', query.revision], + ['cursor', query.cursor], + ['priority', query.priority], + ['limit', query.limit], + ['includeBodies', query.includeBodies ? 'true' : undefined], + ])}`, + { ...options, signal }, + ) + const parsed = FreshAgentTurnPageSchema.safeParse(data) + if (!parsed.success) { + throw new FreshAgentApiContractError('Fresh-agent turn page response did not match the shared contract.', parsed.error.issues) + } + return parsed.data +} + +export async function getFreshAgentTurnBody( + sessionType: string, + provider: string, + threadId: string, + turnId: string, + query: { revision: number; signal?: AbortSignal }, + options: ApiRequestOptions = {}, +): Promise<any> { + const signal = query.signal ?? options.signal + const data = await api.get( + `/api/fresh-agent/threads/${encodeURIComponent(sessionType)}/${encodeURIComponent(provider)}/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}${buildQueryString([ + ['revision', query.revision], + ])}`, + { ...options, signal }, + ) + const parsed = FreshAgentTurnBodySchema.safeParse(data) + if (!parsed.success) { + throw new FreshAgentApiContractError('Fresh-agent turn body response did not match the shared contract.', parsed.error.issues) + } + return parsed.data +} + export async function getTerminalViewport( terminalId: string, options: ApiRequestOptions = {}, @@ -362,6 +443,9 @@ export type SearchResult = { firstUserMessage?: string isSubagent?: boolean isNonInteractive?: boolean + isRunning?: boolean + runningTerminalId?: string + liveTerminalOnly?: boolean } export type SearchResponse = { @@ -413,6 +497,9 @@ function groupDirectoryItemsAsProjects(items: ReadModelSessionDirectoryItem[]) { summary: item.summary, isSubagent: item.isSubagent, isNonInteractive: item.isNonInteractive, + isRunning: item.isRunning, + runningTerminalId: item.runningTerminalId, + liveTerminalOnly: item.liveTerminalOnly, firstUserMessage: item.firstUserMessage, sessionType: item.sessionType, })), @@ -504,6 +591,9 @@ export async function searchSessions(options: SearchOptions): Promise<SearchResp firstUserMessage: item.firstUserMessage, isSubagent: item.isSubagent, isNonInteractive: item.isNonInteractive, + isRunning: item.isRunning, + runningTerminalId: item.runningTerminalId, + liveTerminalOnly: item.liveTerminalOnly, })), tier, query, diff --git a/src/lib/browser-preferences.ts b/src/lib/browser-preferences.ts index ddb784df4..28d969317 100644 --- a/src/lib/browser-preferences.ts +++ b/src/lib/browser-preferences.ts @@ -10,11 +10,11 @@ import { BROWSER_PREFERENCES_STORAGE_KEY as STORAGE_KEY } from '@/store/storage- export const BROWSER_PREFERENCES_STORAGE_KEY = STORAGE_KEY const LEGACY_TERMINAL_FONT_KEY = 'freshell.terminal.fontFamily.v1' -const DEFAULT_SEARCH_RANGE_DAYS = 30 +export const DEFAULT_CLOSED_TAB_RETENTION_DAYS = 30 export type BrowserPreferencesRecord = { settings?: LocalSettingsPatch - tabs?: { searchRangeDays?: number } + tabs?: { closedTabRetentionDays?: number; searchRangeDays?: number } legacyLocalSettingsSeedApplied?: boolean } @@ -45,13 +45,13 @@ function normalizeRecord(value: unknown): BrowserPreferencesRecord { normalized.legacyLocalSettingsSeedApplied = true } - if ( - isRecord(value.tabs) - && typeof value.tabs.searchRangeDays === 'number' - && Number.isFinite(value.tabs.searchRangeDays) - && value.tabs.searchRangeDays >= 1 - ) { - normalized.tabs = { searchRangeDays: Math.floor(value.tabs.searchRangeDays) } + if (isRecord(value.tabs)) { + const rawRetention = typeof value.tabs.closedTabRetentionDays === 'number' + ? value.tabs.closedTabRetentionDays + : value.tabs.searchRangeDays + if (typeof rawRetention === 'number' && Number.isFinite(rawRetention) && rawRetention >= 1) { + normalized.tabs = { closedTabRetentionDays: Math.min(30, Math.floor(rawRetention)) } + } } return normalized @@ -156,18 +156,21 @@ export function patchBrowserPreferencesRecord(patch: BrowserPreferencesRecord): } } - if ( - isRecord(patch.tabs) - && typeof patch.tabs.searchRangeDays === 'number' - && Number.isFinite(patch.tabs.searchRangeDays) - && patch.tabs.searchRangeDays >= 1 - ) { - next = { - ...next, - tabs: { - ...(current.tabs || {}), - searchRangeDays: Math.floor(patch.tabs.searchRangeDays), - }, + if (isRecord(patch.tabs)) { + const rawRetention = typeof patch.tabs.closedTabRetentionDays === 'number' + ? patch.tabs.closedTabRetentionDays + : patch.tabs.searchRangeDays + if (typeof rawRetention === 'number' && Number.isFinite(rawRetention) && rawRetention >= 1) { + const closedTabRetentionDays = Math.min(30, Math.floor(rawRetention)) + const currentTabs = { ...(current.tabs || {}) } + delete currentTabs.searchRangeDays + next = { + ...next, + tabs: { + ...currentTabs, + closedTabRetentionDays, + }, + } } } @@ -210,6 +213,10 @@ export function resolveBrowserPreferenceSettings(record?: BrowserPreferencesReco return resolveLocalSettings(record?.settings) } +export function getClosedTabRetentionDaysPreference(): number { + return loadBrowserPreferencesRecord().tabs?.closedTabRetentionDays ?? DEFAULT_CLOSED_TAB_RETENTION_DAYS +} + export function getSearchRangeDaysPreference(): number { - return loadBrowserPreferencesRecord().tabs?.searchRangeDays ?? DEFAULT_SEARCH_RANGE_DAYS + return getClosedTabRetentionDaysPreference() } diff --git a/src/lib/claude-session-id.ts b/src/lib/claude-session-id.ts index 5cb6efb6a..61b80a93e 100644 --- a/src/lib/claude-session-id.ts +++ b/src/lib/claude-session-id.ts @@ -1,5 +1,5 @@ -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +import { isCanonicalClaudeSessionId } from '@shared/session-contract' export function isValidClaudeSessionId(value?: string): value is string { - return typeof value === 'string' && UUID_REGEX.test(value) + return isCanonicalClaudeSessionId(value) } diff --git a/src/lib/create-cancellation.ts b/src/lib/create-cancellation.ts new file mode 100644 index 000000000..0af65a789 --- /dev/null +++ b/src/lib/create-cancellation.ts @@ -0,0 +1,15 @@ +const cancelledCreateRequestIds = new Set<string>() + +export function cancelCreate(requestId: string): void { + cancelledCreateRequestIds.add(requestId) +} + +export function consumeCancelledCreate(requestId: string): boolean { + if (!cancelledCreateRequestIds.has(requestId)) return false + cancelledCreateRequestIds.delete(requestId) + return true +} + +export function _resetCancelledCreates(): void { + cancelledCreateRequestIds.clear() +} diff --git a/src/lib/derivePaneTitle.ts b/src/lib/derivePaneTitle.ts index 4fc3c10e3..30afb9253 100644 --- a/src/lib/derivePaneTitle.ts +++ b/src/lib/derivePaneTitle.ts @@ -1,6 +1,7 @@ import type { PaneContent } from '@/store/paneTypes' import { getProviderLabel, isNonShellMode } from '@/lib/coding-cli-utils' import { getAgentChatProviderLabel } from '@/lib/agent-chat-utils' +import { getFreshAgentLabel } from '@/lib/fresh-agent-registry' import type { ClientExtensionEntry } from '@shared/extension-types' /** @@ -23,6 +24,10 @@ export function derivePaneTitle(content: PaneContent, extensions?: ClientExtensi return getAgentChatProviderLabel(content.provider) } + if (content.kind === 'fresh-agent') { + return getFreshAgentLabel(content.sessionType) + } + if (content.kind === 'browser') { if (!content.url) return 'Browser' try { diff --git a/src/lib/fresh-agent-api-error.ts b/src/lib/fresh-agent-api-error.ts new file mode 100644 index 000000000..ab81029ba --- /dev/null +++ b/src/lib/fresh-agent-api-error.ts @@ -0,0 +1,11 @@ +export class FreshAgentApiContractError extends Error { + readonly code = 'FRESH_AGENT_CONTRACT_PARSE_FAILED' as const + + constructor( + message: string, + readonly details: unknown, + ) { + super(message) + this.name = 'FreshAgentApiContractError' + } +} diff --git a/src/lib/fresh-agent-registry.ts b/src/lib/fresh-agent-registry.ts new file mode 100644 index 000000000..b12a1c4fe --- /dev/null +++ b/src/lib/fresh-agent-registry.ts @@ -0,0 +1,128 @@ +import { + getFreshAgentDescriptor, + type FreshAgentRuntimeProvider, + type FreshAgentSessionType, +} from '@shared/fresh-agent' +import { + CodexIcon, + FreshclaudeIcon, + KilroyIcon, + OpencodeIcon, +} from '@/components/icons/provider-icons' + +export type FreshAgentRegistryEntry = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + label: string + icon: React.ComponentType<{ className?: string }> + defaultModel: string + defaultPermissionMode: string + defaultEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' + settingsVisibility: { + model: boolean + permissionMode: boolean + effort: boolean + thinking: boolean + tools: boolean + timecodes: boolean + } + pickerShortcut: string + pickerAfterCli?: boolean + hidden?: boolean + disabled?: boolean + featureFlag?: string +} + +export const FRESH_AGENT_REGISTRY: readonly FreshAgentRegistryEntry[] = [ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + label: 'Freshclaude', + icon: FreshclaudeIcon, + defaultModel: 'claude-opus-4-6', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: true, + tools: true, + timecodes: true, + }, + pickerShortcut: 'A', + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + label: 'Freshcodex', + icon: CodexIcon, + defaultModel: 'gpt-5-codex', + defaultPermissionMode: 'on-request', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: false, + tools: true, + timecodes: true, + }, + pickerShortcut: 'X', + pickerAfterCli: true, + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + label: 'Kilroy', + icon: KilroyIcon, + defaultModel: 'claude-opus-4-6', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: true, + tools: true, + timecodes: true, + }, + pickerShortcut: 'K', + pickerAfterCli: true, + hidden: true, + featureFlag: 'kilroy', + }, + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + label: 'Freshopencode', + icon: OpencodeIcon, + defaultModel: 'opencode', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: false, + tools: true, + timecodes: true, + }, + pickerShortcut: 'O', + pickerAfterCli: true, + disabled: true, + }, +] as const + +export function resolveFreshAgentType( + sessionType: string | undefined, +): FreshAgentRegistryEntry | undefined { + if (!sessionType) return undefined + return FRESH_AGENT_REGISTRY.find((entry) => entry.sessionType === sessionType) +} + +export function getFreshAgentLabel(sessionType: string | undefined): string { + return resolveFreshAgentType(sessionType)?.label + ?? getFreshAgentDescriptor(sessionType)?.label + ?? 'Fresh Agent' +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts new file mode 100644 index 000000000..8adf71942 --- /dev/null +++ b/src/lib/fresh-agent-ws.ts @@ -0,0 +1,174 @@ +import type { AppDispatch } from '@/store/store' +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' +import type { SessionRef } from '@shared/session-contract' +import { consumeCancelledCreate } from '@/lib/create-cancellation' +import { + clearPendingCreateFailure, + createFailed, + markSessionLost, + registerPendingCreate, + sessionError, + sessionCreated, + sessionInit, + sessionMetadataReceived, + sessionSnapshotReceived, + setSessionStatus, +} from '@/store/freshAgentSlice' + +type FreshAgentCreatedMessage = { + type: 'freshAgent.created' + requestId: string + sessionId: string + sessionType: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider + runtimeProvider?: FreshAgentRuntimeProvider +} + +type FreshAgentCreateFailedMessage = { + type: 'freshAgent.create.failed' + requestId: string + code: string + message: string + retryable?: boolean +} + +type FreshAgentClientMessage = FreshAgentCreatedMessage | FreshAgentCreateFailedMessage + +interface FreshAgentMessageSink { + send: (msg: unknown) => void +} + +type FreshAgentEventMessage = { + type: 'freshAgent.event' + sessionId: string + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + event: Record<string, unknown> +} + +export function registerFreshAgentCreate( + dispatch: AppDispatch, + requestId: string, + options: { + resumeSessionId?: string + sessionRef?: SessionRef + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + }, +): void { + dispatch(registerPendingCreate({ + requestId, + sessionType: options.sessionType, + provider: options.provider, + expectsHistoryHydration: Boolean(options.resumeSessionId || options.sessionRef), + })) + dispatch(clearPendingCreateFailure({ requestId })) +} + +export function handleFreshAgentMessage(dispatch: AppDispatch, msg: Record<string, unknown>, ws?: FreshAgentMessageSink): boolean { + switch (msg.type) { + case 'freshAgent.created': { + const created = msg as FreshAgentCreatedMessage + const provider = created.provider ?? created.runtimeProvider + if (consumeCancelledCreate(created.requestId)) { + if (provider) { + ws?.send({ + type: 'freshAgent.kill', + sessionId: created.sessionId, + sessionType: created.sessionType, + provider, + }) + } + return true + } + dispatch(sessionCreated({ + requestId: created.requestId, + sessionId: created.sessionId, + sessionType: created.sessionType, + provider, + })) + return true + } + case 'freshAgent.create.failed': { + const failed = msg as FreshAgentCreateFailedMessage + dispatch(createFailed({ + requestId: failed.requestId, + code: failed.code, + message: failed.message, + retryable: failed.retryable, + })) + return true + } + case 'freshAgent.event': + return handleFreshAgentTransportEvent(dispatch, msg as FreshAgentEventMessage) + default: + return false + } +} + +export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: FreshAgentEventMessage): boolean { + const event = msg.event + const sessionId = typeof msg.sessionId === 'string' + ? msg.sessionId + : (typeof event.sessionId === 'string' ? event.sessionId : undefined) + if (!sessionId || typeof event?.type !== 'string') return false + + const locator = { + sessionId, + sessionType: msg.sessionType, + provider: msg.provider, + } + + switch (event.type) { + case 'sdk.session.snapshot': + dispatch(sessionSnapshotReceived({ + ...locator, + latestTurnId: (event.latestTurnId as string | null | undefined) ?? null, + status: event.status as never, + timelineSessionId: event.timelineSessionId as string | undefined, + revision: event.revision as number | undefined, + streamingActive: event.streamingActive as boolean | undefined, + streamingText: event.streamingText as string | undefined, + })) + return true + case 'sdk.session.init': + dispatch(sessionInit({ + ...locator, + cliSessionId: event.cliSessionId as string | undefined, + model: event.model as string | undefined, + cwd: event.cwd as string | undefined, + tools: event.tools as Array<{ name: string }> | undefined, + })) + return true + case 'sdk.session.metadata': + dispatch(sessionMetadataReceived({ + ...locator, + cliSessionId: event.cliSessionId as string | undefined, + model: event.model as string | undefined, + cwd: event.cwd as string | undefined, + tools: event.tools as Array<{ name: string }> | undefined, + })) + return true + case 'sdk.status': + dispatch(setSessionStatus({ + ...locator, + status: event.status as never, + })) + return true + case 'sdk.error': + if (event.code === 'INVALID_SESSION_ID') { + dispatch(markSessionLost(locator)) + } else { + dispatch(sessionError({ + ...locator, + code: event.code as string | undefined, + message: (event.message as string) || (event.error as string) || 'Unknown error', + })) + } + return true + default: + return false + } +} + +export type { FreshAgentClientMessage, FreshAgentCreatedMessage, FreshAgentCreateFailedMessage, FreshAgentEventMessage } diff --git a/src/lib/import-retry.ts b/src/lib/import-retry.ts new file mode 100644 index 000000000..bb0083234 --- /dev/null +++ b/src/lib/import-retry.ts @@ -0,0 +1,56 @@ +const CHUNK_ERROR_RE = + /(?:failed to fetch|error loading).*dynamically imported module|importing a module script|loading chunk \d+ failed/i + +export const RELOAD_KEY = 'freshell.chunk-reload' +const RELOAD_COOLDOWN_MS = 10_000 + +export function isChunkLoadError(err: unknown): boolean { + return err instanceof TypeError && CHUNK_ERROR_RE.test(err.message) +} + +export function shouldReload(): boolean { + try { + const last = sessionStorage.getItem(RELOAD_KEY) + if (last && Date.now() - parseInt(last, 10) < RELOAD_COOLDOWN_MS) { + return false + } + sessionStorage.setItem(RELOAD_KEY, String(Date.now())) + return true + } catch { + return true + } +} + +export function withChunkErrorRecovery<T>(importPromise: Promise<T>): Promise<T> { + return importPromise.catch((err: unknown) => { + if (isChunkLoadError(err)) { + if (shouldReload()) { + window.location.reload() + return new Promise<never>(() => {}) + } + throw err + } + throw err + }) +} + +let recoveryInitialized = false + +export function initChunkErrorRecovery(): void { + if (recoveryInitialized) return + recoveryInitialized = true + + window.addEventListener('vite:preloadError', (event) => { + if (shouldReload()) { + event.preventDefault() + window.location.reload() + } + }) + + window.addEventListener('unhandledrejection', (event) => { + if (isChunkLoadError(event.reason) && shouldReload()) { + event.preventDefault() + window.location.reload() + } + }) +} diff --git a/src/lib/known-devices.ts b/src/lib/known-devices.ts index 08d7f7f41..3e59a8ad7 100644 --- a/src/lib/known-devices.ts +++ b/src/lib/known-devices.ts @@ -1,5 +1,3 @@ -import type { RegistryTabRecord } from '@/store/tabRegistryTypes' - export type KnownDevice = { key: string deviceIds: string[] @@ -14,9 +12,11 @@ type BuildKnownDevicesInput = { ownDeviceLabel: string deviceAliases?: Record<string, string> dismissedDeviceIds?: string[] - localOpen?: RegistryTabRecord[] - remoteOpen?: RegistryTabRecord[] - closed?: RegistryTabRecord[] + localOpen?: unknown[] + sameDeviceOpen?: unknown[] + remoteOpen?: unknown[] + closed?: unknown[] + devices?: Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> } type DeviceGroup = { @@ -27,11 +27,6 @@ type DeviceGroup = { lastSeenAt: number } -function pushUnique(values: string[], value: string): void { - if (!value || values.includes(value)) return - values.push(value) -} - function resolveEffectiveLabel(deviceIds: string[], aliases: Record<string, string>, fallbackLabel: string): string { for (const deviceId of deviceIds) { const alias = aliases[deviceId] @@ -42,23 +37,22 @@ function resolveEffectiveLabel(deviceIds: string[], aliases: Record<string, stri return fallbackLabel } -function upsertRemoteGroup(groups: Map<string, DeviceGroup>, record: RegistryTabRecord): void { - // Collapse device-id rotations from the same machine into one row using the stored machine label. - const key = `remote:${record.deviceLabel}` +function upsertRemoteDevice(groups: Map<string, DeviceGroup>, device: { deviceId: string; deviceLabel: string; lastSeenAt: number }): void { + const key = `remote:${device.deviceId}` const current = groups.get(key) if (!current) { groups.set(key, { key, - deviceIds: [record.deviceId], - baseLabel: record.deviceLabel, + deviceIds: [device.deviceId], + baseLabel: device.deviceLabel, isOwn: false, - lastSeenAt: record.closedAt ?? record.updatedAt, + lastSeenAt: device.lastSeenAt, }) return } - pushUnique(current.deviceIds, record.deviceId) - current.lastSeenAt = Math.max(current.lastSeenAt, record.closedAt ?? record.updatedAt) + current.baseLabel = device.deviceLabel + current.lastSeenAt = Math.max(current.lastSeenAt, device.lastSeenAt) } export function buildKnownDevices(input: BuildKnownDevicesInput): KnownDevice[] { @@ -74,18 +68,9 @@ export function buildKnownDevices(input: BuildKnownDevicesInput): KnownDevice[] lastSeenAt: Number.MAX_SAFE_INTEGER, }) - for (const record of [ - ...(input.localOpen ?? []), - ...(input.remoteOpen ?? []), - ...(input.closed ?? []), - ]) { - if (record.deviceId === input.ownDeviceId) { - continue - } - if (dismissedDeviceIds.has(record.deviceId)) { - continue - } - upsertRemoteGroup(groups, record) + for (const device of input.devices ?? []) { + if (device.deviceId === input.ownDeviceId || dismissedDeviceIds.has(device.deviceId)) continue + upsertRemoteDevice(groups, device) } return [...groups.values()] diff --git a/src/lib/pane-activity.ts b/src/lib/pane-activity.ts index 84d73efd0..df6beb0a5 100644 --- a/src/lib/pane-activity.ts +++ b/src/lib/pane-activity.ts @@ -1,19 +1,23 @@ import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' import { resolveExactCodexActivity } from '@/lib/codex-activity-resolver' import { collectPaneEntries } from '@/lib/pane-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import type { ChatSessionState } from '@/store/agentChatTypes' +import type { FreshAgentSessionState } from '@/store/freshAgentTypes' import type { AgentChatPaneContent, + FreshAgentPaneContent, PaneContent, PaneNode, TerminalPaneContent, } from '@/store/paneTypes' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' import { getPreferredResumeSessionId } from '@/store/persistControl' +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import type { Tab } from '@/store/types' import type { CodexActivityRecord, OpencodeActivityRecord } from '@shared/ws-protocol' -type PaneActivitySource = 'codex' | 'opencode' | 'claude-terminal' | 'agent-chat' | 'browser' +type PaneActivitySource = 'codex' | 'opencode' | 'claude-terminal' | 'agent-chat' | 'fresh-agent' | 'browser' export type PaneActivityProjection = { isBusy: boolean @@ -50,6 +54,21 @@ function resolveAgentChatSessionKey( return `${provider}:${sessionId}` } +function resolveFreshAgentSessionKey( + content: FreshAgentPaneContent, + session: FreshAgentSessionState | undefined, +): string | undefined { + const explicit = content.sessionRef + if (explicit?.provider && explicit.sessionId) { + return `${explicit.provider}:${explicit.sessionId}` + } + + const provider = resolveFreshAgentType(content.sessionType)?.runtimeProvider ?? content.provider + const sessionId = session?.sessionId ?? content.resumeSessionId + if (!provider || !sessionId) return undefined + return `${provider}:${sessionId}` +} + function isAgentChatBusy( content: AgentChatPaneContent, session: ChatSessionState | undefined, @@ -67,6 +86,25 @@ function isAgentChatBusy( return status === 'running' } +function isFreshAgentBusy( + content: FreshAgentPaneContent, + session: FreshAgentSessionState | undefined, +): boolean { + const status = session?.status ?? content.status + if (status === 'compacting') return true + const hasWaitingItems = session != null && ( + Object.keys(session.pendingPermissions).length > 0 + || Object.keys(session.pendingQuestions).length > 0 + ) + if (hasWaitingItems) return false + if (session?.streamingActive) return true + + if (content.provider === 'codex') { + return status === 'running' + } + return status === 'running' +} + function resolveTerminalSessionKey( content: TerminalPaneContent, fallbackSessionRef?: Tab['sessionRef'], @@ -115,6 +153,7 @@ export function resolvePaneActivity(input: { opencodeActivityByTerminalId: Record<string, OpencodeActivityRecord> paneRuntimeActivityByPaneId: Record<string, PaneRuntimeActivityRecord> agentChatSessions: Record<string, ChatSessionState> + freshAgentSessions?: Record<string, FreshAgentSessionState> }): PaneActivityProjection { const runtimeActivity = input.paneRuntimeActivityByPaneId[input.paneId] @@ -167,6 +206,19 @@ export function resolvePaneActivity(input: { : IDLE_PANE_ACTIVITY } + if (input.content.kind === 'fresh-agent') { + const session = input.content.sessionId + ? input.freshAgentSessions?.[makeFreshAgentSessionKey({ + sessionType: input.content.sessionType, + provider: input.content.provider, + sessionId: input.content.sessionId, + })] + : undefined + return isFreshAgentBusy(input.content, session) + ? { isBusy: true, source: 'fresh-agent' } + : IDLE_PANE_ACTIVITY + } + return IDLE_PANE_ACTIVITY } @@ -177,6 +229,7 @@ export function getBusyPaneIdsForTab(input: { opencodeActivityByTerminalId: Record<string, OpencodeActivityRecord> paneRuntimeActivityByPaneId: Record<string, PaneRuntimeActivityRecord> agentChatSessions: Record<string, ChatSessionState> + freshAgentSessions?: Record<string, FreshAgentSessionState> }): string[] { const layout = input.paneLayouts[input.tab.id] if (!layout) { @@ -192,6 +245,7 @@ export function getBusyPaneIdsForTab(input: { opencodeActivityByTerminalId: input.opencodeActivityByTerminalId, paneRuntimeActivityByPaneId: input.paneRuntimeActivityByPaneId, agentChatSessions: input.agentChatSessions, + freshAgentSessions: input.freshAgentSessions, }).isBusy ? [input.tab.id] : [] @@ -208,6 +262,7 @@ export function getBusyPaneIdsForTab(input: { opencodeActivityByTerminalId: input.opencodeActivityByTerminalId, paneRuntimeActivityByPaneId: input.paneRuntimeActivityByPaneId, agentChatSessions: input.agentChatSessions, + freshAgentSessions: input.freshAgentSessions, }).isBusy) .map((entry) => entry.paneId) } @@ -219,6 +274,7 @@ export function collectBusySessionKeys(input: { opencodeActivityByTerminalId: Record<string, OpencodeActivityRecord> paneRuntimeActivityByPaneId: Record<string, PaneRuntimeActivityRecord> agentChatSessions: Record<string, ChatSessionState> + freshAgentSessions?: Record<string, FreshAgentSessionState> }): string[] { const busySessionKeys = new Set<string>() @@ -237,6 +293,7 @@ export function collectBusySessionKeys(input: { opencodeActivityByTerminalId: input.opencodeActivityByTerminalId, paneRuntimeActivityByPaneId: input.paneRuntimeActivityByPaneId, agentChatSessions: input.agentChatSessions, + freshAgentSessions: input.freshAgentSessions, }).isBusy if (!busy) continue @@ -256,6 +313,7 @@ export function collectBusySessionKeys(input: { opencodeActivityByTerminalId: input.opencodeActivityByTerminalId, paneRuntimeActivityByPaneId: input.paneRuntimeActivityByPaneId, agentChatSessions: input.agentChatSessions, + freshAgentSessions: input.freshAgentSessions, }).isBusy if (!busy) continue @@ -266,6 +324,17 @@ export function collectBusySessionKeys(input: { ? input.agentChatSessions[entry.content.sessionId] : undefined, ) + : entry.content.kind === 'fresh-agent' + ? resolveFreshAgentSessionKey( + entry.content, + entry.content.sessionId + ? input.freshAgentSessions?.[makeFreshAgentSessionKey({ + sessionType: entry.content.sessionType, + provider: entry.content.provider, + sessionId: entry.content.sessionId, + })] + : undefined, + ) : entry.content.kind === 'terminal' ? resolveTerminalSessionKey(entry.content, tab.sessionRef, tab.resumeSessionId, tab.mode) : undefined diff --git a/src/lib/sdk-message-handler.ts b/src/lib/sdk-message-handler.ts index a1aafed68..7c7f2a2b0 100644 --- a/src/lib/sdk-message-handler.ts +++ b/src/lib/sdk-message-handler.ts @@ -1,6 +1,7 @@ import type { AppDispatch } from '@/store/store' import type { ChatContentBlock } from '@/store/agentChatTypes' import type { QuestionDefinition } from '@/store/agentChatTypes' +import { consumeCancelledCreate } from '@/lib/create-cancellation' import { sessionCreated, createFailed, @@ -20,22 +21,7 @@ import { markSessionLost, removeSession, } from '@/store/agentChatSlice' - -/** - * Tracks createRequestIds whose owning pane was closed before sdk.created arrived. - * When sdk.created arrives for a cancelled ID, we skip session creation and send sdk.kill. - */ -const cancelledCreateRequestIds = new Set<string>() - -/** Mark a createRequestId as cancelled so the arriving sdk.created will be killed. */ -export function cancelCreate(requestId: string): void { - cancelledCreateRequestIds.add(requestId) -} - -/** Visible for testing — clear all cancelled creates. */ -export function _resetCancelledCreates(): void { - cancelledCreateRequestIds.clear() -} +export { cancelCreate, _resetCancelledCreates } from '@/lib/create-cancellation' interface SdkMessageSink { send: (msg: unknown) => void @@ -52,8 +38,7 @@ export function handleSdkMessage(dispatch: AppDispatch, msg: Record<string, unkn const requestId = msg.requestId as string const sessionId = msg.sessionId as string // If the pane was closed before sdk.created arrived, kill the orphan - if (cancelledCreateRequestIds.has(requestId)) { - cancelledCreateRequestIds.delete(requestId) + if (consumeCancelledCreate(requestId)) { ws?.send({ type: 'sdk.kill', sessionId }) return true } diff --git a/src/lib/session-type-utils.ts b/src/lib/session-type-utils.ts index de9c3feb7..74b3b15d6 100644 --- a/src/lib/session-type-utils.ts +++ b/src/lib/session-type-utils.ts @@ -2,9 +2,10 @@ import type { ComponentType } from 'react' import { PROVIDER_ICONS, DefaultProviderIcon } from '@/components/icons/provider-icons' import { isNonShellMode, getProviderLabel } from '@/lib/coding-cli-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import type { AgentChatProviderName, AgentChatProviderSettings } from '@/lib/agent-chat-types' import type { CodingCliProviderName } from '@/store/types' -import type { AgentChatPaneInput, TerminalPaneInput } from '@/store/paneTypes' +import type { FreshAgentPaneInput, AgentChatPaneInput, TerminalPaneInput } from '@/store/paneTypes' import type { ClientExtensionEntry } from '@shared/extension-types' export interface SessionTypeConfig { @@ -13,6 +14,14 @@ export interface SessionTypeConfig { } export function resolveSessionTypeConfig(sessionType: string, extensions?: ClientExtensionEntry[]): SessionTypeConfig { + const freshAgentType = resolveFreshAgentType(sessionType) + if (freshAgentType) { + return { + icon: freshAgentType.icon, + label: freshAgentType.label, + } + } + // 1. Check agent-chat providers first (they have explicit configs) const agentConfig = getAgentChatProviderConfig(sessionType) if (agentConfig) { @@ -47,19 +56,42 @@ export function buildResumeContent(opts: { sessionId: string cwd?: string agentChatProviderSettings?: AgentChatProviderSettings -}): TerminalPaneInput | AgentChatPaneInput { - const agentConfig = getAgentChatProviderConfig(opts.sessionType) - if (agentConfig) { + liveTerminal?: { + terminalId: string + serverInstanceId: string + } +}): TerminalPaneInput | FreshAgentPaneInput | AgentChatPaneInput { + const freshAgentType = resolveFreshAgentType(opts.sessionType) + if (freshAgentType) { + const agentConfig = getAgentChatProviderConfig(opts.sessionType) const ps = opts.agentChatProviderSettings return { - kind: 'agent-chat', - provider: agentConfig.name as AgentChatProviderName, + kind: 'fresh-agent', + sessionType: freshAgentType.sessionType, + provider: freshAgentType.runtimeProvider, + resumeSessionId: opts.sessionId, sessionRef: { - provider: agentConfig.codingCliProvider ?? 'claude', + provider: freshAgentType.runtimeProvider, sessionId: opts.sessionId, }, initialCwd: opts.cwd, modelSelection: ps?.modelSelection, + model: freshAgentType.defaultModel, + permissionMode: ps?.defaultPermissionMode ?? agentConfig?.defaultPermissionMode ?? freshAgentType.defaultPermissionMode, + effort: ps?.effort, + } + } + + const agentConfig = getAgentChatProviderConfig(opts.sessionType) + if (agentConfig) { + const ps = opts.agentChatProviderSettings + return { + kind: 'fresh-agent', + sessionType: agentConfig.name as AgentChatProviderName, + provider: 'claude', + resumeSessionId: opts.sessionId, + initialCwd: opts.cwd, + modelSelection: ps?.modelSelection, permissionMode: ps?.defaultPermissionMode ?? agentConfig.defaultPermissionMode, effort: ps?.effort, } @@ -71,6 +103,13 @@ export function buildResumeContent(opts: { return { kind: 'terminal', mode: provider, + ...(opts.liveTerminal + ? { + terminalId: opts.liveTerminal.terminalId, + serverInstanceId: opts.liveTerminal.serverInstanceId, + status: 'running' as const, + } + : {}), sessionRef: { provider, sessionId: opts.sessionId, diff --git a/src/lib/session-utils.ts b/src/lib/session-utils.ts index 6d5663f20..306a52bdd 100644 --- a/src/lib/session-utils.ts +++ b/src/lib/session-utils.ts @@ -3,6 +3,7 @@ */ import { isNonShellMode } from '@/lib/coding-cli-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import type { PaneContent, PaneNode } from '@/store/paneTypes' import type { RootState } from '@/store/store' import type { CodingCliProviderName } from '@/store/types' @@ -83,6 +84,13 @@ function extractExplicitSessionLocator(content: PaneContent): { return sanitizeSessionLocator(explicit) } +function extractCodexDurabilityLocator(content: PaneContent): SessionMatchLocator | undefined { + if (content.kind !== 'terminal' || content.mode !== 'codex') return undefined + const sessionId = content.codexDurability?.durableThreadId + ?? content.codexDurability?.candidate?.candidateThreadId + return sessionId ? { provider: 'codex', sessionId } : undefined +} + function extractSessionLocatorServerInstanceHint(content: PaneContent): string | undefined { return isNonEmptyString((content as { serverInstanceId?: unknown }).serverInstanceId) ? (content as { serverInstanceId: string }).serverInstanceId @@ -119,9 +127,20 @@ function extractSessionLocators(content: PaneContent): Array<{ locators.push({ provider: 'claude', sessionId }) return dedupeBy(locators, locatorIdentity) } + if (content.kind === 'fresh-agent') { + const sessionId = content.resumeSessionId + const provider = resolveFreshAgentType(content.sessionType)?.runtimeProvider ?? content.provider + if (!sessionId || !provider) return dedupeBy(locators, locatorIdentity) + locators.push({ provider, sessionId }) + return dedupeBy(locators, locatorIdentity) + } if (content.kind !== 'terminal') return dedupeBy(locators, locatorIdentity) if (content.mode === 'shell') return dedupeBy(locators, locatorIdentity) if (!isNonShellMode(content.mode)) return dedupeBy(locators, locatorIdentity) + const codexDurabilityLocator = extractCodexDurabilityLocator(content) + if (codexDurabilityLocator) { + locators.push(codexDurabilityLocator) + } const sessionId = content.resumeSessionId if (!sessionId || content.mode !== 'claude' || !isValidClaudeSessionId(sessionId)) { return dedupeBy(locators, locatorIdentity) @@ -136,6 +155,11 @@ function buildTabFallbackLocator(tab: RootState['tabs']['tabs'][number]): Sessio return explicitSessionRef } const provider = tab.codingCliProvider || (tab.mode !== 'shell' ? tab.mode : undefined) + if (provider === 'codex') { + const sessionId = tab.codexDurability?.durableThreadId + ?? tab.codexDurability?.candidate?.candidateThreadId + if (sessionId) return sanitizeSessionLocator({ provider, sessionId }) + } const sessionId = tab.resumeSessionId if (provider !== 'claude' || !sessionId || !isValidClaudeSessionId(sessionId)) return undefined return sanitizeSessionLocator({ provider, sessionId }) @@ -353,6 +377,11 @@ export function findTabIdForSession( return selectBestSessionMatch(candidates, sanitizedTarget, localServerInstanceId)?.tabId } +/** + * Find the tab and pane that contain a specific session. + * Walks all tabs' pane trees looking for a pane (terminal, agent-chat, or fresh-agent) matching the provider + sessionId. + * Falls back to tab-level resumeSessionId when no layout exists (early boot/rehydration). + */ export function findPaneForSession( state: RootState, target: SessionMatchLocator, diff --git a/src/lib/tab-directory-preference.ts b/src/lib/tab-directory-preference.ts index b9fa8dcca..418e64d12 100644 --- a/src/lib/tab-directory-preference.ts +++ b/src/lib/tab-directory-preference.ts @@ -9,7 +9,7 @@ export type TabDirectoryPreference = { /** * Walk a pane tree and compute directory preference for the tab. - * Counts initialCwd occurrences across terminal and agent-chat panes. + * Counts initialCwd occurrences across terminal and rich-agent panes. * Returns the most-used directory (alphabetical tiebreaker) and a * frequency-sorted list of all tab directories. */ @@ -19,7 +19,7 @@ export function getTabDirectoryPreference(root: PaneNode): TabDirectoryPreferenc function walk(node: PaneNode): void { if (node.type === 'leaf') { const content = node.content - if (content.kind === 'terminal' || content.kind === 'agent-chat') { + if (content.kind === 'terminal' || content.kind === 'agent-chat' || content.kind === 'fresh-agent') { const cwd = content.initialCwd?.trim() if (cwd) { counts.set(cwd, (counts.get(cwd) ?? 0) + 1) diff --git a/src/lib/tab-fallback-identity.ts b/src/lib/tab-fallback-identity.ts index 4e973ff5f..5e6f13c0d 100644 --- a/src/lib/tab-fallback-identity.ts +++ b/src/lib/tab-fallback-identity.ts @@ -29,6 +29,18 @@ function deriveLeafSessionRef( }) } + if (content.kind === 'fresh-agent') { + const explicit = sanitizeSessionRef(content.sessionRef) + if (explicit) return explicit + if (isValidClaudeSessionId(content.resumeSessionId)) { + return sanitizeSessionRef({ + provider: 'claude', + sessionId: content.resumeSessionId, + }) + } + return undefined + } + return undefined } diff --git a/src/lib/tab-recency.ts b/src/lib/tab-recency.ts new file mode 100644 index 000000000..e4a5c6dd2 --- /dev/null +++ b/src/lib/tab-recency.ts @@ -0,0 +1,39 @@ +import type { PaneNode } from '@/store/paneTypes' +import type { Tab } from '@/store/types' + +export const TAB_RECENCY_RESOLUTION_MS = 60 * 1000 + +type TimestampCandidate = number | null | undefined + +export function bucketTabRecencyAt(at: TimestampCandidate): number | undefined { + if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) return undefined + return Math.floor(at / TAB_RECENCY_RESOLUTION_MS) * TAB_RECENCY_RESOLUTION_MS +} + +export function collectTerminalPaneIds(node: PaneNode | undefined): string[] { + if (!node) return [] + if (node.type === 'leaf') { + return node.content.kind === 'terminal' ? [node.id] : [] + } + return [ + ...collectTerminalPaneIds(node.children[0]), + ...collectTerminalPaneIds(node.children[1]), + ] +} + +export function deriveTabRecencyAt(input: { + tab: Pick<Tab, 'createdAt' | 'lastInputAt'> + layout: PaneNode | undefined + paneLastInputAt: Record<string, number | undefined> +}): number { + const candidates: number[] = [] + for (const raw of [input.tab.createdAt, input.tab.lastInputAt]) { + const bucket = bucketTabRecencyAt(raw) + if (bucket !== undefined) candidates.push(bucket) + } + for (const paneId of collectTerminalPaneIds(input.layout)) { + const bucket = bucketTabRecencyAt(input.paneLastInputAt[paneId]) + if (bucket !== undefined) candidates.push(bucket) + } + return candidates.length > 0 ? Math.max(...candidates) : 0 +} diff --git a/src/lib/tab-registry-snapshot.ts b/src/lib/tab-registry-snapshot.ts index 7f77757f4..5c3635549 100644 --- a/src/lib/tab-registry-snapshot.ts +++ b/src/lib/tab-registry-snapshot.ts @@ -17,6 +17,7 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor mode: content.mode, shell: content.shell, sessionRef: content.sessionRef, + codexDurability: content.mode === 'codex' ? content.codexDurability : undefined, liveTerminal: content.terminalId ? { terminalId: content.terminalId, @@ -38,26 +39,27 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor viewMode: content.viewMode, } case 'agent-chat': - { - const sessionRef = content.sessionRef - || (content.resumeSessionId - ? { - provider: 'claude', - sessionId: content.resumeSessionId, - serverInstanceId, - } - : undefined) - return { - provider: content.provider, - sessionId: content.sessionId, - resumeSessionId: content.resumeSessionId, - sessionRef, - initialCwd: content.initialCwd, - modelSelection: content.modelSelection, - permissionMode: content.permissionMode, - effort: content.effort, - plugins: content.plugins, - } + return { + provider: content.provider, + sessionRef: content.sessionRef, + initialCwd: content.initialCwd, + modelSelection: content.modelSelection, + permissionMode: content.permissionMode, + effort: content.effort, + plugins: content.plugins, + } + case 'fresh-agent': + return { + provider: content.provider, + sessionType: content.sessionType, + sessionRef: content.sessionRef, + initialCwd: content.initialCwd, + model: content.provider === 'codex' ? content.model : undefined, + modelSelection: content.provider === 'claude' ? content.modelSelection : undefined, + permissionMode: content.permissionMode, + sandbox: content.sandbox, + effort: content.effort, + plugins: content.plugins, } case 'extension': return { diff --git a/src/lib/terminal-invalidation-handler.ts b/src/lib/terminal-invalidation-handler.ts new file mode 100644 index 000000000..82eea7cb5 --- /dev/null +++ b/src/lib/terminal-invalidation-handler.ts @@ -0,0 +1,98 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import type { TerminalMetaRecord } from '@/store/terminalMetaSlice' + +type DispatchLike = (action: unknown) => unknown + +type RefreshThunk = unknown + +type TerminalInvalidationDeps = { + dispatch: DispatchLike + upsertTerminalMeta: (payload: TerminalMetaRecord[]) => PayloadAction<TerminalMetaRecord[]> + removeTerminalMeta: (terminalId: string) => PayloadAction<string> + patchSessionRunningStateFromTerminalMeta: (payload: { + upsert: TerminalMetaRecord[] + remove: string[] + }) => PayloadAction<{ + upsert: TerminalMetaRecord[] + remove: string[] + }> + queueActiveSessionWindowRefresh: () => RefreshThunk + fetchTerminalDirectoryWindow: (payload: { surface: 'sidebar'; priority: 'visible' }) => RefreshThunk + setTimeout?: typeof setTimeout + clearTimeout?: typeof clearTimeout + refreshDelayMs?: number +} + +function isTerminalMetaRecord(value: unknown): value is TerminalMetaRecord { + return !!value + && typeof value === 'object' + && typeof (value as { terminalId?: unknown }).terminalId === 'string' + && typeof (value as { updatedAt?: unknown }).updatedAt === 'number' +} + +export function createTerminalInvalidationHandler(deps: TerminalInvalidationDeps) { + const setTimer = deps.setTimeout ?? setTimeout + const clearTimer = deps.clearTimeout ?? clearTimeout + const delayMs = deps.refreshDelayMs ?? 50 + let refreshTimer: ReturnType<typeof setTimeout> | undefined + + const runRefresh = () => { + refreshTimer = undefined + deps.dispatch(deps.fetchTerminalDirectoryWindow({ + surface: 'sidebar', + priority: 'visible', + })) + deps.dispatch(deps.queueActiveSessionWindowRefresh()) + } + + const scheduleRefresh = () => { + if (refreshTimer) clearTimer(refreshTimer) + refreshTimer = setTimer(runRefresh, delayMs) + } + + return { + handle(msg: { type?: unknown; upsert?: unknown; remove?: unknown }): boolean { + if (msg.type === 'terminal.meta.updated') { + const upsert = Array.isArray(msg.upsert) + ? msg.upsert.filter(isTerminalMetaRecord) + : [] + if (upsert.length > 0) { + deps.dispatch(deps.upsertTerminalMeta(upsert)) + } + + const remove = Array.isArray(msg.remove) + ? msg.remove.filter((terminalId): terminalId is string => typeof terminalId === 'string') + : [] + for (const terminalId of remove) { + deps.dispatch(deps.removeTerminalMeta(terminalId)) + } + + deps.dispatch(deps.patchSessionRunningStateFromTerminalMeta({ + upsert, + remove, + })) + scheduleRefresh() + return true + } + + if (msg.type === 'terminals.changed') { + scheduleRefresh() + return true + } + + return false + }, + + flush() { + if (!refreshTimer) return + clearTimer(refreshTimer) + runRefresh() + }, + + dispose() { + if (!refreshTimer) return + clearTimer(refreshTimer) + refreshTimer = undefined + }, + } +} diff --git a/src/lib/terminal-restore.ts b/src/lib/terminal-restore.ts index 374bf5f84..74da670e0 100644 --- a/src/lib/terminal-restore.ts +++ b/src/lib/terminal-restore.ts @@ -7,6 +7,8 @@ type PaneNode = { } const restoredCreateRequestIds = new Set<string>() +export type TerminalFreshRecoveryIntent = 'fresh_after_restore_unavailable' +const freshRecoveryRequestIds = new Map<string, TerminalFreshRecoveryIntent>() function collectCreateRequestIds(node: PaneNode | null | undefined): void { if (!node) return @@ -31,11 +33,31 @@ if (persisted?.layouts && typeof persisted.layouts === 'object') { } export function consumeTerminalRestoreRequestId(requestId: string): boolean { + if (freshRecoveryRequestIds.has(requestId)) return false if (!restoredCreateRequestIds.has(requestId)) return false restoredCreateRequestIds.delete(requestId) return true } export function addTerminalRestoreRequestId(requestId: string): void { + if (freshRecoveryRequestIds.has(requestId)) return restoredCreateRequestIds.add(requestId) } + +export function consumeTerminalFreshRecoveryRequest( + requestId: string, +): TerminalFreshRecoveryIntent | undefined { + const intent = freshRecoveryRequestIds.get(requestId) + if (!intent) return undefined + freshRecoveryRequestIds.delete(requestId) + restoredCreateRequestIds.delete(requestId) + return intent +} + +export function addTerminalFreshRecoveryRequestId( + requestId: string, + intent: TerminalFreshRecoveryIntent, +): void { + restoredCreateRequestIds.delete(requestId) + freshRecoveryRequestIds.set(requestId, intent) +} diff --git a/src/lib/test-harness.ts b/src/lib/test-harness.ts index bc16d6c52..82a63596f 100644 --- a/src/lib/test-harness.ts +++ b/src/lib/test-harness.ts @@ -1,5 +1,6 @@ import type { store as appStore } from '@/store/store' import type { PerfAuditSnapshot } from '@/lib/perf-audit-bridge' +import type { ServerMessage } from '@shared/ws-protocol' export interface FreshellTestHarness { getState: () => ReturnType<typeof appStore.getState> @@ -8,6 +9,7 @@ export interface FreshellTestHarness { waitForConnection: (timeoutMs?: number) => Promise<void> forceDisconnect: () => void sendWsMessage: (msg: unknown) => void + receiveWsMessage?: (msg: ServerMessage) => void setAgentChatNetworkEffectsSuppressed: (paneId: string, suppressed: boolean) => void isAgentChatNetworkEffectsSuppressed: (paneId: string) => boolean setTerminalNetworkEffectsSuppressed: (paneId: string, suppressed: boolean) => void @@ -41,10 +43,20 @@ export function installTestHarness( waitForWsReady: (timeoutMs?: number) => Promise<void>, forceWsDisconnect: () => void, sendWsMessage: (msg: unknown) => void, + receiveWsMessageOrGetPerfAuditSnapshot?: ((msg: ServerMessage) => void) | (() => PerfAuditSnapshot | null), getPerfAuditSnapshot: () => PerfAuditSnapshot | null = () => null, ): void { if (typeof window === 'undefined') return + let resolvedReceiveWsMessage: ((msg: ServerMessage) => void) | undefined + let resolvedGetPerfAuditSnapshot = getPerfAuditSnapshot + if (arguments.length <= 6) { + resolvedGetPerfAuditSnapshot = (receiveWsMessageOrGetPerfAuditSnapshot as (() => PerfAuditSnapshot | null) | undefined) + ?? (() => null) + } else if (receiveWsMessageOrGetPerfAuditSnapshot) { + resolvedReceiveWsMessage = receiveWsMessageOrGetPerfAuditSnapshot as (msg: ServerMessage) => void + } + // Registry of terminal buffer accessors, keyed by terminalId. // TerminalView registers/unregisters accessors as xterm instances mount/unmount. const terminalBuffers = new Map<string, () => string>() @@ -67,6 +79,7 @@ export function installTestHarness( waitForConnection: waitForWsReady, forceDisconnect: forceWsDisconnect, sendWsMessage: sendWsMessage, + receiveWsMessage: resolvedReceiveWsMessage, getTerminalBuffer: (terminalId?: string) => { if (terminalId) { const accessor = terminalBuffers.get(terminalId) @@ -99,7 +112,7 @@ export function installTestHarness( unregisterTerminalBuffer: (terminalId: string) => { terminalBuffers.delete(terminalId) }, - getPerfAuditSnapshot, + getPerfAuditSnapshot: resolvedGetPerfAuditSnapshot, getSentWsMessages: () => [...sentWsMessages], clearSentWsMessages: () => { sentWsMessages.length = 0 diff --git a/src/lib/ui-commands.ts b/src/lib/ui-commands.ts index 17f201d78..0cfd7048b 100644 --- a/src/lib/ui-commands.ts +++ b/src/lib/ui-commands.ts @@ -123,6 +123,7 @@ export function handleUiCommand(msg: any, runtimeOrDispatch: UiCommandRuntime | case 'pane.close': return dispatch(closePaneWithCleanup({ tabId: msg.payload.tabId, paneId: msg.payload.paneId })) case 'pane.select': + dispatch(setActiveTab(msg.payload.tabId)) return dispatch(setActivePane({ tabId: msg.payload.tabId, paneId: msg.payload.paneId })) case 'pane.rename': return dispatch(applyPaneRename({ diff --git a/src/lib/ws-client.ts b/src/lib/ws-client.ts index df05c3c99..d82cf4eba 100644 --- a/src/lib/ws-client.ts +++ b/src/lib/ws-client.ts @@ -24,12 +24,20 @@ type HelloExtensionProvider = () => { type TabsSyncPushPayload = { deviceId: string deviceLabel: string + clientInstanceId: string + snapshotRevision: number records: unknown[] } type TabsSyncQueryPayload = { requestId: string deviceId: string - rangeDays?: number + clientInstanceId: string + closedTabRetentionDays: number +} +type TabsSyncClientRetirePayload = { + deviceId: string + clientInstanceId: string + snapshotRevision: number } type TerminalInputClientMessage = { @@ -48,12 +56,17 @@ type SdkCreateClientMessage = { requestId: string } +type FreshAgentCreateClientMessage = { + type: 'freshAgent.create' + requestId: string +} + type TerminalAttachClientMessage = { type: 'terminal.attach' terminalId: string } -type CreateClientMessage = TerminalCreateClientMessage | SdkCreateClientMessage +type CreateClientMessage = TerminalCreateClientMessage | SdkCreateClientMessage | FreshAgentCreateClientMessage type InFlightCreate = { message: CreateClientMessage @@ -61,7 +74,7 @@ type InFlightCreate = { } const CONNECTION_TIMEOUT_MS = 10_000 -const WS_PROTOCOL_VERSION = 4 +const WS_PROTOCOL_VERSION = 5 const perfConfig = getClientPerfConfig() function isTerminalInputMessage(msg: unknown): msg is TerminalInputClientMessage { @@ -75,7 +88,7 @@ function isTerminalInputMessage(msg: unknown): msg is TerminalInputClientMessage function isCreateMessage(msg: unknown): msg is CreateClientMessage { if (!msg || typeof msg !== 'object') return false const candidate = msg as { type?: unknown; requestId?: unknown } - return (candidate.type === 'terminal.create' || candidate.type === 'sdk.create') + return (candidate.type === 'terminal.create' || candidate.type === 'sdk.create' || candidate.type === 'freshAgent.create') && typeof candidate.requestId === 'string' && candidate.requestId.length > 0 } @@ -120,6 +133,124 @@ export class WsClient { constructor(private url: string) {} + private clearTrackedCreate(requestId: string): void { + this.inFlightCreates.delete(requestId) + this.preReadyCreateQueue.delete(requestId) + } + + cancelCreate(requestId: string): void { + this.clearTrackedCreate(requestId) + } + + private handleIncomingMessage(msg: ServerMessage): void { + if (msg.type === 'ready') { + this._serverInstanceId = typeof msg.serverInstanceId === 'string' && msg.serverInstanceId.trim() + ? msg.serverInstanceId + : undefined + this.clearReadyTimeout() + const isReconnect = this.wasConnectedOnce + this.wasConnectedOnce = true + this._state = 'ready' + if (isReconnect) { + this.reconnectEpoch += 1 + } + + if (perfConfig.enabled && this.connectStartedAt !== null) { + const durationMs = performance.now() - this.connectStartedAt + this.connectStartedAt = null + if (durationMs >= perfConfig.wsReadySlowMs) { + logClientPerf('perf.ws_ready_slow', { + durationMs: Number(durationMs.toFixed(2)), + reconnect: isReconnect, + }, 'warn') + } else { + logClientPerf('perf.ws_ready', { + durationMs: Number(durationMs.toFixed(2)), + reconnect: isReconnect, + }) + } + } + + const createRequestIdsFlushed = new Set<string>() + for (const [requestId, createMsg] of this.preReadyCreateQueue.entries()) { + if (!this.inFlightCreates.has(requestId)) continue + this.sendNow(createMsg) + createRequestIdsFlushed.add(requestId) + } + this.preReadyCreateQueue.clear() + + const pendingMessages = isReconnect + ? this.pendingMessages.filter((queued) => !isTerminalAttachMessage(queued)) + : this.pendingMessages + this.pendingMessages = [] + + for (const next of pendingMessages) { + if (!next) continue + this.sendNow(next) + } + + if (isReconnect) { + for (const [requestId, entry] of this.inFlightCreates.entries()) { + if (entry.lastResendEpoch === this.reconnectEpoch) continue + if (createRequestIdsFlushed.has(requestId)) { + entry.lastResendEpoch = this.reconnectEpoch + continue + } + this.sendNow(entry.message) + entry.lastResendEpoch = this.reconnectEpoch + } + } + + if (isReconnect) { + this.reconnectHandlers.forEach((h) => h()) + } + } + + if (msg.type === 'terminal.output' && typeof msg.terminalId === 'string') { + markTerminalOutputSeen(msg.terminalId) + } + + if ( + msg.type === 'terminal.created' + || msg.type === 'sdk.created' + || msg.type === 'sdk.create.failed' + || msg.type === 'freshAgent.created' + || msg.type === 'freshAgent.create.failed' + ) { + this.clearTrackedCreate(msg.requestId) + } + + if (msg.type === 'error' && typeof msg.requestId === 'string') { + this.clearTrackedCreate(msg.requestId) + } + + if (msg.type === 'error' && msg.code === 'NOT_AUTHENTICATED') { + this.clearReadyTimeout() + this.intentionalClose = true + return + } + + if (msg.type === 'error' && msg.code === 'PROTOCOL_MISMATCH') { + this.clearReadyTimeout() + this.intentionalClose = true + return + } + + if (perfConfig.enabled) { + const start = performance.now() + this.messageHandlers.forEach((handler) => handler(msg)) + const durationMs = performance.now() - start + if (durationMs >= perfConfig.wsMessageSlowMs) { + logClientPerf('perf.ws_message_handlers_slow', { + durationMs: Number(durationMs.toFixed(2)), + messageType: msg?.type, + }, 'warn') + } + } else { + this.messageHandlers.forEach((handler) => handler(msg)) + } + } + /** * Set a provider for additional data to include in the hello message. * Used to send session IDs for prioritized repair scanning. @@ -216,119 +347,25 @@ export class WsClient { // Ignore invalid JSON return } - + this.handleIncomingMessage(msg) if (msg.type === 'ready') { - this._serverInstanceId = typeof msg.serverInstanceId === 'string' && msg.serverInstanceId.trim() - ? msg.serverInstanceId - : undefined - this.clearReadyTimeout() - const isReconnect = this.wasConnectedOnce - this.wasConnectedOnce = true - this._state = 'ready' - if (isReconnect) { - this.reconnectEpoch += 1 - } - - if (perfConfig.enabled && this.connectStartedAt !== null) { - const durationMs = performance.now() - this.connectStartedAt - this.connectStartedAt = null - if (durationMs >= perfConfig.wsReadySlowMs) { - logClientPerf('perf.ws_ready_slow', { - durationMs: Number(durationMs.toFixed(2)), - reconnect: isReconnect, - }, 'warn') - } else { - logClientPerf('perf.ws_ready', { - durationMs: Number(durationMs.toFixed(2)), - reconnect: isReconnect, - }) - } - } - - const createRequestIdsFlushed = new Set<string>() - for (const [requestId, createMsg] of this.preReadyCreateQueue.entries()) { - if (!this.inFlightCreates.has(requestId)) continue - this.sendNow(createMsg) - createRequestIdsFlushed.add(requestId) - } - this.preReadyCreateQueue.clear() - - const pendingMessages = isReconnect - ? this.pendingMessages.filter((msg) => !isTerminalAttachMessage(msg)) - : this.pendingMessages - this.pendingMessages = [] - - for (const next of pendingMessages) { - if (!next) continue - this.sendNow(next) - } - - if (isReconnect) { - for (const [requestId, entry] of this.inFlightCreates.entries()) { - if (entry.lastResendEpoch === this.reconnectEpoch) continue - if (createRequestIdsFlushed.has(requestId)) { - entry.lastResendEpoch = this.reconnectEpoch - continue - } - this.sendNow(entry.message) - entry.lastResendEpoch = this.reconnectEpoch - } - } - - if (isReconnect) { - this.reconnectHandlers.forEach((h) => h()) - } - finishResolve() + return } - - if (msg.type === 'terminal.output' && typeof msg.terminalId === 'string') { - markTerminalOutputSeen(msg.terminalId) - } - - if (msg.type === 'terminal.created' || msg.type === 'sdk.created' || msg.type === 'sdk.create.failed') { - const create = this.inFlightCreates.get(msg.requestId) - if (create) { - this.inFlightCreates.delete(msg.requestId) - this.preReadyCreateQueue.delete(msg.requestId) - } - } - - if (msg.type === 'error' && typeof msg.requestId === 'string') { - this.inFlightCreates.delete(msg.requestId) - this.preReadyCreateQueue.delete(msg.requestId) - } - if (msg.type === 'error' && msg.code === 'NOT_AUTHENTICATED') { - this.clearReadyTimeout() - this.intentionalClose = true const err = new Error('Authentication failed') ;(err as any).wsCloseCode = 4001 finishReject(err) return } - if (msg.type === 'error' && msg.code === 'PROTOCOL_MISMATCH') { this.clearReadyTimeout() this.intentionalClose = true - const err = new Error('Protocol version mismatch') + const err = new Error(typeof msg.message === 'string' && msg.message + ? msg.message + : 'Protocol version mismatch. Reload this Freshell browser tab to use the latest client bundle.') ;(err as any).wsCloseCode = 4010 finishReject(err) - return - } - - if (perfConfig.enabled) { - const start = performance.now() - this.messageHandlers.forEach((handler) => handler(msg)) - const durationMs = performance.now() - start - if (durationMs >= perfConfig.wsMessageSlowMs) { - logClientPerf('perf.ws_message_handlers_slow', { - durationMs: Number(durationMs.toFixed(2)), - messageType: msg?.type, - }, 'warn') - } - } else { - this.messageHandlers.forEach((handler) => handler(msg)) } } @@ -545,6 +582,13 @@ export class WsClient { }) } + sendTabsSyncClientRetire(payload: TabsSyncClientRetirePayload) { + this.send({ + type: 'tabs.sync.client.retire', + ...payload, + }) + } + onMessage(handler: MessageHandler): () => void { this.messageHandlers.add(handler) return () => this.messageHandlers.delete(handler) @@ -560,6 +604,10 @@ export class WsClient { return () => this.disconnectHandlers.delete(handler) } + receiveMessageForTest(msg: ServerMessage): void { + this.handleIncomingMessage(msg) + } + private sendNow(msg: unknown) { this.ws?.send(JSON.stringify(msg)) this.outboundMessageObserver?.(msg) diff --git a/src/main.tsx b/src/main.tsx index 69d7d60a7..1f1a13d04 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,11 +9,13 @@ import { initializeAuthToken } from '@/lib/auth' import { createClientLogger } from '@/lib/client-logger' import { initClientPerfLogging } from '@/lib/perf-logger' import { registerServiceWorker } from '@/lib/pwa' +import { initChunkErrorRecovery } from '@/lib/import-retry' initializeAuthToken() createClientLogger().installConsoleCapture() initClientPerfLogging() registerServiceWorker() +initChunkErrorRecovery() if (import.meta.env.DEV) { document.title = 'freshell:dev' diff --git a/src/store/agentChatSlice.ts b/src/store/agentChatSlice.ts index e57caee1a..c4eb8c9b7 100644 --- a/src/store/agentChatSlice.ts +++ b/src/store/agentChatSlice.ts @@ -214,9 +214,11 @@ const agentChatSlice = createSlice({ }>) { const session = ensureSession(state, action.payload.sessionId) const previousRestoreQueryId = getRestoreQueryId(session) - const nextTimelineSessionId = isValidClaudeSessionId(action.payload.timelineSessionId) + const payloadTimelineSessionId = typeof action.payload.timelineSessionId === 'string' + && action.payload.timelineSessionId.trim().length > 0 ? action.payload.timelineSessionId : undefined + const nextTimelineSessionId = payloadTimelineSessionId ?? session.timelineSessionId const nextRestoreQueryId = getRestoreQueryId({ cliSessionId: session.cliSessionId, timelineSessionId: nextTimelineSessionId ?? session.timelineSessionId, diff --git a/src/store/browserPreferencesPersistence.ts b/src/store/browserPreferencesPersistence.ts index 124725b42..4b7df4b50 100644 --- a/src/store/browserPreferencesPersistence.ts +++ b/src/store/browserPreferencesPersistence.ts @@ -1,7 +1,7 @@ import type { Middleware } from '@reduxjs/toolkit' import { mergeLocalSettings, defaultLocalSettings, type LocalSettings, type LocalSettingsPatch } from '@shared/settings' -import { loadBrowserPreferencesRecord, type BrowserPreferencesRecord } from '@/lib/browser-preferences' +import { DEFAULT_CLOSED_TAB_RETENTION_DAYS, loadBrowserPreferencesRecord, type BrowserPreferencesRecord } from '@/lib/browser-preferences' import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys' import { broadcastPersistedRaw } from './persistBroadcast' import type { SettingsState } from './settingsSlice' @@ -11,16 +11,16 @@ export const BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS = 500 type BrowserPreferencesState = { settings: SettingsState - tabRegistry: Pick<TabRegistryState, 'searchRangeDays'> + tabRegistry: Pick<TabRegistryState, 'closedTabRetentionDays' | 'searchRangeDays'> } type BrowserPreferencesWriteState = { settingsPatch?: LocalSettingsPatch - hasPendingSearchRangeDays: boolean - searchRangeDays: number + hasPendingClosedTabRetentionDays: boolean + closedTabRetentionDays: number } -const DEFAULT_SEARCH_RANGE_DAYS = 30 +const DEFAULT_SEARCH_RANGE_DAYS = DEFAULT_CLOSED_TAB_RETENTION_DAYS const flushCallbacks = new Set<() => void>() let flushListenersAttached = false @@ -109,6 +109,7 @@ export function buildLocalSettingsPatch(localSettings: LocalSettings): LocalSett assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'tabAttentionStyle') assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'attentionDismiss') assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'sessionOpenMode') + assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'multirowTabs') if (Object.keys(panes).length > 0) { patch.panes = panes } @@ -126,12 +127,13 @@ export function buildLocalSettingsPatch(localSettings: LocalSettings): LocalSett patch.sidebar = sidebar } - const agentChat: LocalSettingsPatch['agentChat'] = {} - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showThinking') - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showTools') - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showTimecodes') - if (Object.keys(agentChat).length > 0) { - patch.agentChat = agentChat + const freshAgent: LocalSettingsPatch['freshAgent'] = {} + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showThinking') + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showTools') + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showTimecodes') + if (Object.keys(freshAgent).length > 0) { + patch.freshAgent = freshAgent + patch.agentChat = freshAgent } const notifications: LocalSettingsPatch['notifications'] = {} @@ -156,9 +158,10 @@ function buildBrowserPreferencesRecord(state: BrowserPreferencesState): BrowserP next.settings = settingsPatch } - if (state.tabRegistry.searchRangeDays !== DEFAULT_SEARCH_RANGE_DAYS) { + const closedTabRetentionDays = Math.min(30, Math.max(1, state.tabRegistry.closedTabRetentionDays ?? state.tabRegistry.searchRangeDays)) + if (closedTabRetentionDays !== DEFAULT_SEARCH_RANGE_DAYS) { next.tabs = { - searchRangeDays: state.tabRegistry.searchRangeDays, + closedTabRetentionDays, } } @@ -172,8 +175,8 @@ function getOrCreatePendingWriteState(getState: BrowserPreferencesMiddlewareGetS } const created: BrowserPreferencesWriteState = { - hasPendingSearchRangeDays: false, - searchRangeDays: DEFAULT_SEARCH_RANGE_DAYS, + hasPendingClosedTabRetentionDays: false, + closedTabRetentionDays: DEFAULT_SEARCH_RANGE_DAYS, } pendingWritesByGetState.set(getState, created) return created @@ -181,8 +184,8 @@ function getOrCreatePendingWriteState(getState: BrowserPreferencesMiddlewareGetS function resetPendingWriteState(getState: BrowserPreferencesMiddlewareGetState) { pendingWritesByGetState.set(getState, { - hasPendingSearchRangeDays: false, - searchRangeDays: DEFAULT_SEARCH_RANGE_DAYS, + hasPendingClosedTabRetentionDays: false, + closedTabRetentionDays: DEFAULT_SEARCH_RANGE_DAYS, }) } @@ -192,12 +195,16 @@ export function getPendingBrowserPreferencesWriteState(store: { getState: Browse return { hasPendingSearchRangeDays: false, searchRangeDays: DEFAULT_SEARCH_RANGE_DAYS, + hasPendingClosedTabRetentionDays: false, + closedTabRetentionDays: DEFAULT_SEARCH_RANGE_DAYS, } } return { settingsPatch: pending.settingsPatch, - hasPendingSearchRangeDays: pending.hasPendingSearchRangeDays, - searchRangeDays: pending.searchRangeDays, + hasPendingSearchRangeDays: pending.hasPendingClosedTabRetentionDays, + searchRangeDays: pending.closedTabRetentionDays, + hasPendingClosedTabRetentionDays: pending.hasPendingClosedTabRetentionDays, + closedTabRetentionDays: pending.closedTabRetentionDays, } } @@ -254,6 +261,7 @@ export const browserPreferencesPersistenceMiddleware: Middleware<{}, BrowserPref action?.type === 'settings/updateSettingsLocal' || action?.type === 'settings/setLocalSettings' || action?.type === 'tabRegistry/setTabRegistrySearchRangeDays' + || action?.type === 'tabRegistry/setTabRegistryClosedTabRetentionDays' ) { const pending = getOrCreatePendingWriteState(store.getState as BrowserPreferencesMiddlewareGetState) if (action?.type === 'settings/updateSettingsLocal') { @@ -261,9 +269,12 @@ export const browserPreferencesPersistenceMiddleware: Middleware<{}, BrowserPref } else if (action?.type === 'settings/setLocalSettings') { const nextPatch = buildLocalSettingsPatch(action.payload as LocalSettings) pending.settingsPatch = Object.keys(nextPatch).length > 0 ? nextPatch : undefined - } else if (action?.type === 'tabRegistry/setTabRegistrySearchRangeDays') { - pending.hasPendingSearchRangeDays = true - pending.searchRangeDays = action.payload + } else if ( + action?.type === 'tabRegistry/setTabRegistrySearchRangeDays' + || action?.type === 'tabRegistry/setTabRegistryClosedTabRetentionDays' + ) { + pending.hasPendingClosedTabRetentionDays = true + pending.closedTabRetentionDays = Math.min(30, Math.max(1, Math.floor(action.payload))) } retrySuppressed = false dirty = true diff --git a/src/store/crossTabSync.ts b/src/store/crossTabSync.ts index 28233a995..c7c1352ac 100644 --- a/src/store/crossTabSync.ts +++ b/src/store/crossTabSync.ts @@ -2,13 +2,19 @@ import { z } from 'zod' import { mergeLocalSettings, resolveLocalSettings } from '@shared/settings' import { hydratePanes } from './panesSlice' import { setLocalSettings } from './settingsSlice' -import { setTabRegistrySearchRangeDays } from './tabRegistrySlice' +import { setTabRegistryClosedTabRetentionDays } from './tabRegistrySlice' import { hydrateTabs } from './tabsSlice' import { getPendingBrowserPreferencesWriteState } from './browserPreferencesPersistence' import { parsePersistedLayoutRaw, LAYOUT_STORAGE_KEY } from './persistedState' import { getPersistBroadcastSourceId, onPersistBroadcast, PERSIST_BROADCAST_CHANNEL_NAME } from './persistBroadcast' import { shouldPreserveLocalCanonicalResumeSessionId } from './persistControl' -import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys' +import { BROWSER_PREFERENCES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys' +import { collectLiveTerminalPaneIds } from './tabRecencyPruneMiddleware' +import { + loadPersistedTabRecency, + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, +} from './tabRecencySlice' import { parseBrowserPreferencesRaw, resolveBrowserPreferenceSettings } from '@/lib/browser-preferences' type StoreLike = { @@ -16,7 +22,7 @@ type StoreLike = { getState: () => any } -const DEFAULT_SEARCH_RANGE_DAYS = 30 +const DEFAULT_CLOSED_TAB_RETENTION_DAYS = 30 const zPersistBroadcastMsg = z.object({ type: z.literal('persist'), @@ -25,6 +31,12 @@ const zPersistBroadcastMsg = z.object({ sourceId: z.string(), }) +const CROSS_TAB_SYNC_STORAGE_KEYS = [ + LAYOUT_STORAGE_KEY, + BROWSER_PREFERENCES_STORAGE_KEY, + TAB_RECENCY_STORAGE_KEY, +] as const + function collectPaneIdsSafe(node: unknown): string[] { const ids: string[] = [] @@ -80,7 +92,7 @@ function buildCanonicalClaudeSessionRef(localContent: any, localResumeSessionId: } if ( - localContent?.kind === 'agent-chat' + (localContent?.kind === 'agent-chat' || localContent?.kind === 'fresh-agent') || (localContent?.kind === 'terminal' && localContent?.mode === 'claude') ) { return { @@ -100,7 +112,11 @@ function protectCanonicalPaneResumeIdentity(remoteNode: unknown, localLayout: un const localResumeSessionId = localContent?.resumeSessionId const remoteResumeSessionId = candidate.content?.resumeSessionId if ( - (candidate.content?.kind === 'terminal' || candidate.content?.kind === 'agent-chat') + ( + candidate.content?.kind === 'terminal' + || candidate.content?.kind === 'agent-chat' + || candidate.content?.kind === 'fresh-agent' + ) && shouldPreserveLocalCanonicalResumeSessionId(localResumeSessionId, remoteResumeSessionId) ) { const preservedSessionRef = buildCanonicalClaudeSessionRef(localContent, localResumeSessionId) @@ -212,8 +228,10 @@ function dispatchHydrateBrowserPreferencesFromPersisted( const previousParsed = previousRaw ? parseBrowserPreferencesRaw(previousRaw) : null const remoteResetSettingsToDefaults = previousParsed?.settings !== undefined && parsed.settings === undefined - const remoteResetSearchRangeToDefault = - previousParsed?.tabs?.searchRangeDays !== undefined && parsed.tabs?.searchRangeDays === undefined + const previousRetention = previousParsed?.tabs?.closedTabRetentionDays ?? previousParsed?.tabs?.searchRangeDays + const parsedRetention = parsed.tabs?.closedTabRetentionDays ?? parsed.tabs?.searchRangeDays + const remoteResetRetentionToDefault = + previousRetention !== undefined && parsedRetention === undefined const pendingWriteState = getPendingBrowserPreferencesWriteState(store) const remoteSettingsPatch = parsed.settings ?? {} let mergedSettingsPatch = remoteSettingsPatch @@ -223,9 +241,11 @@ function dispatchHydrateBrowserPreferencesFromPersisted( const nextSettings = pendingWriteState.settingsPatch ? resolveLocalSettings(mergedSettingsPatch) : resolveBrowserPreferenceSettings(parsed) - const nextSearchRangeDays = pendingWriteState.hasPendingSearchRangeDays - ? pendingWriteState.searchRangeDays - : (parsed.tabs?.searchRangeDays ?? DEFAULT_SEARCH_RANGE_DAYS) + const hasPendingRetention = pendingWriteState.hasPendingClosedTabRetentionDays ?? pendingWriteState.hasPendingSearchRangeDays + const pendingRetention = pendingWriteState.closedTabRetentionDays ?? pendingWriteState.searchRangeDays + const nextClosedTabRetentionDays = hasPendingRetention + ? pendingRetention + : (parsedRetention ?? DEFAULT_CLOSED_TAB_RETENTION_DAYS) if ( parsed.settings @@ -238,12 +258,12 @@ function dispatchHydrateBrowserPreferencesFromPersisted( }) } if ( - parsed.tabs?.searchRangeDays !== undefined - || remoteResetSearchRangeToDefault - || pendingWriteState.hasPendingSearchRangeDays + parsedRetention !== undefined + || remoteResetRetentionToDefault + || hasPendingRetention ) { store.dispatch({ - ...setTabRegistrySearchRangeDays(nextSearchRangeDays), + ...setTabRegistryClosedTabRetentionDays(nextClosedTabRetentionDays), meta: { skipPersist: true, source: 'cross-tab' }, }) } @@ -260,6 +280,17 @@ function handleIncomingRaw( dispatchHydrateLayoutFromPersisted(store, raw, localLayoutPersistedAt) } else if (key === BROWSER_PREFERENCES_STORAGE_KEY) { dispatchHydrateBrowserPreferencesFromPersisted(store, raw, previousRaw) + } else if (key === TAB_RECENCY_STORAGE_KEY) { + store.dispatch({ + ...mergeHydratedTabRecency(loadPersistedTabRecency(raw)), + meta: { skipPersist: true, source: 'cross-tab' }, + }) + store.dispatch({ + ...prunePaneTabActivityToLiveTerminalPanes({ + paneIds: collectLiveTerminalPaneIds(store.getState()), + }), + meta: { source: 'cross-tab' }, + }) } } @@ -270,7 +301,7 @@ export function installCrossTabSync(store: StoreLike): () => void { // Dedupe by exact raw value so we don't hydrate twice. const lastProcessedRawByKey = new Map<string, string>() let currentLocalLayoutPersistedAt: number | undefined - for (const key of [LAYOUT_STORAGE_KEY, BROWSER_PREFERENCES_STORAGE_KEY]) { + for (const key of CROSS_TAB_SYNC_STORAGE_KEYS) { const existingRaw = localStorage.getItem(key) if (typeof existingRaw === 'string') { lastProcessedRawByKey.set(key, existingRaw) @@ -307,10 +338,7 @@ export function installCrossTabSync(store: StoreLike): () => void { // then diverge locally (persisted raw changes), a later remote event with the original raw // could be incorrectly ignored. const unsubscribeLocal = onPersistBroadcast((msg) => { - if ( - msg.key !== LAYOUT_STORAGE_KEY - && msg.key !== BROWSER_PREFERENCES_STORAGE_KEY - ) { + if (!CROSS_TAB_SYNC_STORAGE_KEYS.includes(msg.key as any)) { return } lastProcessedRawByKey.set(msg.key, msg.raw) @@ -322,10 +350,7 @@ export function installCrossTabSync(store: StoreLike): () => void { const onStorage = (e: StorageEvent) => { if (e.storageArea && e.storageArea !== localStorage) return const key = e.key - if ( - key !== LAYOUT_STORAGE_KEY - && key !== BROWSER_PREFERENCES_STORAGE_KEY - ) { + if (typeof key !== 'string' || !CROSS_TAB_SYNC_STORAGE_KEYS.includes(key as any)) { return } if (typeof e.newValue !== 'string') return diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts new file mode 100644 index 000000000..b0b2c0e7b --- /dev/null +++ b/src/store/freshAgentSlice.ts @@ -0,0 +1,441 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { + makeFreshAgentSessionKey, + type FreshAgentRuntimeProvider, + type FreshAgentSessionType, +} from '@shared/fresh-agent' +import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import type { + FreshAgentPermissionRequest, + FreshAgentQuestionRequest, + FreshAgentSessionState, + FreshAgentSessionStatus, + FreshAgentState, + PendingCreateFailure, +} from './freshAgentTypes' + +type FreshAgentSessionPayload = { + sessionId: string + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider +} + +type SessionMutationPayload = { + sessionId: string + sessionType?: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider +} + +const initialState: FreshAgentState = { + sessions: {}, + pendingCreates: {}, + pendingCreateFailures: {}, + availableModels: [], +} + +function sessionKey(locator: FreshAgentSessionPayload): string { + return makeFreshAgentSessionKey(locator) +} + +function resolveSessionKey( + state: FreshAgentState, + payload: SessionMutationPayload, +): string | undefined { + if (payload.sessionType && payload.provider) { + return sessionKey({ + sessionId: payload.sessionId, + sessionType: payload.sessionType, + provider: payload.provider, + }) + } + + return Object.values(state.sessions).find((session) => session.sessionId === payload.sessionId)?.sessionKey +} + +function createSession(locator: FreshAgentSessionPayload, status: FreshAgentSessionStatus): FreshAgentSessionState { + const key = sessionKey(locator) + return { + ...locator, + sessionKey: key, + threadId: locator.sessionId, + status, + turns: [], + timelineItems: [], + timelineBodies: {}, + streamingText: '', + streamingActive: false, + pendingPermissions: {}, + pendingQuestions: {}, + totalCostUsd: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + historyLoaded: false, + } +} + +function ensureSession( + state: FreshAgentState, + locator: FreshAgentSessionPayload, + status: FreshAgentSessionStatus = 'starting', +): FreshAgentSessionState { + const key = sessionKey(locator) + state.sessions[key] ??= createSession(locator, status) + return state.sessions[key] +} + +function resolveOrEnsureSession( + state: FreshAgentState, + payload: SessionMutationPayload, + status: FreshAgentSessionStatus = 'starting', +): FreshAgentSessionState | undefined { + const key = resolveSessionKey(state, payload) + if (key && state.sessions[key]) return state.sessions[key] + if (!payload.sessionType || !payload.provider) return undefined + return ensureSession(state, { + sessionId: payload.sessionId, + sessionType: payload.sessionType, + provider: payload.provider, + }, status) +} + +function resetHydratedTimelineState(session: FreshAgentSessionState): void { + session.latestTurnId = undefined + session.turns = [] + session.timelineItems = [] + session.timelineBodies = {} + session.nextTimelineCursor = undefined + session.timelineLoading = false + session.timelineError = undefined + session.historyLoaded = false + session.restoreFailureMessage = undefined + session.streamingText = '' + session.streamingActive = false +} + +function requestRestoreHydrationRestart(session: FreshAgentSessionState): void { + session.restoreHydrationRequestId = (session.restoreHydrationRequestId ?? 0) + 1 +} + +const freshAgentSlice = createSlice({ + name: 'freshAgent', + initialState, + reducers: { + registerPendingCreate(state, action: PayloadAction<{ + requestId: string + expectsHistoryHydration: boolean + sessionType?: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider + }>) { + const current = state.pendingCreates[action.payload.requestId] + state.pendingCreates[action.payload.requestId] = { + sessionId: current?.sessionId, + sessionKey: current?.sessionKey, + sessionType: action.payload.sessionType ?? current?.sessionType, + provider: action.payload.provider ?? current?.provider, + expectsHistoryHydration: action.payload.expectsHistoryHydration, + } + }, + + clearPendingCreate(state, action: PayloadAction<{ requestId: string }>) { + delete state.pendingCreates[action.payload.requestId] + }, + + sessionCreated(state, action: PayloadAction<{ + requestId: string + sessionId: string + sessionType?: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider + }>) { + const pending = state.pendingCreates[action.payload.requestId] + const sessionType = action.payload.sessionType ?? pending?.sessionType + const provider = action.payload.provider ?? pending?.provider + if (!sessionType || !provider) return + + const locator = { sessionId: action.payload.sessionId, sessionType, provider } + const key = sessionKey(locator) + const expectsHistoryHydration = pending?.expectsHistoryHydration ?? false + const session = ensureSession(state, locator, 'connected') + session.status = session.status === 'starting' || session.status === 'creating' + ? 'connected' + : session.status + session.historyLoaded = !expectsHistoryHydration + session.awaitingDurableHistory = expectsHistoryHydration + session.restoreRetryCount = 0 + session.restoreFailureCode = undefined + session.restoreFailureMessage = undefined + session.lost = false + + state.pendingCreates[action.payload.requestId] = { + sessionId: action.payload.sessionId, + sessionKey: key, + sessionType, + provider, + expectsHistoryHydration, + } + }, + + sessionInit(state, action: PayloadAction<SessionMutationPayload & { + cliSessionId?: string + model?: string + cwd?: string + tools?: Array<{ name: string }> + }>) { + const session = resolveOrEnsureSession(state, action.payload) + if (!session) return + session.cliSessionId = action.payload.cliSessionId + session.model = action.payload.model + session.cwd = action.payload.cwd + session.tools = action.payload.tools + session.awaitingDurableHistory = action.payload.cliSessionId ? false : session.awaitingDurableHistory + if (session.status === 'creating' || session.status === 'starting') { + session.status = 'connected' + } + }, + + sessionMetadataReceived(state, action: PayloadAction<SessionMutationPayload & { + cliSessionId?: string + model?: string + cwd?: string + tools?: Array<{ name: string }> + }>) { + const session = resolveOrEnsureSession(state, action.payload) + if (!session) return + session.cliSessionId = action.payload.cliSessionId ?? session.cliSessionId + session.timelineSessionId = action.payload.cliSessionId ?? session.timelineSessionId + session.model = action.payload.model ?? session.model + session.cwd = action.payload.cwd ?? session.cwd + session.tools = action.payload.tools ?? session.tools + if (action.payload.cliSessionId) { + session.awaitingDurableHistory = false + } + }, + + sessionSnapshotReceived(state, action: PayloadAction<SessionMutationPayload & { + latestTurnId: string | null + status: FreshAgentSessionStatus + timelineSessionId?: string + revision?: number + streamingActive?: boolean + streamingText?: string + }>) { + const session = resolveOrEnsureSession(state, action.payload, action.payload.status) + if (!session) return + const shouldRestartHydration = Boolean( + session.historyLoaded + && action.payload.revision != null + && session.timelineRevision != null + && action.payload.revision !== session.timelineRevision, + ) + if (shouldRestartHydration) { + resetHydratedTimelineState(session) + requestRestoreHydrationRestart(session) + } + + session.latestTurnId = action.payload.latestTurnId + session.status = action.payload.status + session.timelineSessionId = action.payload.timelineSessionId ?? session.timelineSessionId + session.timelineRevision = action.payload.revision ?? session.timelineRevision + session.streamingActive = action.payload.streamingActive ?? false + session.streamingText = action.payload.streamingText ?? '' + session.restoreFailureCode = undefined + session.restoreFailureMessage = undefined + session.snapshotRefreshRequestId = undefined + if (action.payload.latestTurnId === null && !session.awaitingDurableHistory) { + session.historyLoaded = true + } else if (action.payload.latestTurnId !== null) { + session.awaitingDurableHistory = false + } + }, + + freshAgentSnapshotReceived(state, action: PayloadAction<{ snapshot: FreshAgentSnapshot }>) { + const snapshot = action.payload.snapshot + const session = ensureSession(state, { + sessionId: snapshot.threadId, + sessionType: snapshot.sessionType, + provider: snapshot.provider, + }, snapshot.status as FreshAgentSessionStatus) + session.snapshot = snapshot + session.status = snapshot.status as FreshAgentSessionStatus + session.latestTurnId = snapshot.latestTurnId + session.timelineRevision = snapshot.revision + session.turns = snapshot.turns + session.timelineItems = snapshot.turns + session.timelineBodies = Object.fromEntries(snapshot.turns.map((turn) => [turn.turnId, turn])) + session.pendingPermissions = Object.fromEntries( + snapshot.pendingApprovals.map((approval) => [String(approval.requestId), approval]), + ) + session.pendingQuestions = Object.fromEntries( + snapshot.pendingQuestions.map((question) => [String(question.requestId), question]), + ) + session.totalInputTokens = snapshot.tokenUsage.inputTokens + session.totalOutputTokens = snapshot.tokenUsage.outputTokens + session.totalCostUsd = snapshot.tokenUsage.costUsd ?? 0 + session.historyLoaded = true + session.awaitingDurableHistory = false + }, + + setSessionStatus(state, action: PayloadAction<SessionMutationPayload & { status: FreshAgentSessionStatus }>) { + const session = resolveOrEnsureSession(state, action.payload, action.payload.status) + if (!session) return + session.status = action.payload.status + }, + + setAvailableModels(state, action: PayloadAction<Array<{ value: string; displayName: string; description: string }>>) { + state.availableModels = action.payload + }, + + addPermissionRequest(state, action: PayloadAction<SessionMutationPayload & FreshAgentPermissionRequest>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].pendingPermissions[String(action.payload.requestId)] = action.payload + }, + + removePermission(state, action: PayloadAction<SessionMutationPayload & { requestId: string | number }>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + delete state.sessions[key].pendingPermissions[String(action.payload.requestId)] + }, + + addQuestionRequest(state, action: PayloadAction<SessionMutationPayload & FreshAgentQuestionRequest>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].pendingQuestions[String(action.payload.requestId)] = action.payload + }, + + removeQuestion(state, action: PayloadAction<SessionMutationPayload & { requestId: string | number }>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + delete state.sessions[key].pendingQuestions[String(action.payload.requestId)] + }, + + sessionError(state, action: PayloadAction<SessionMutationPayload & { code?: string; message: string }>) { + const session = resolveOrEnsureSession(state, action.payload) + if (!session) return + session.lastError = action.payload.message + if (action.payload.code?.startsWith('RESTORE_')) { + session.awaitingDurableHistory = false + session.historyLoaded = true + session.timelineLoading = false + session.restoreFailureCode = action.payload.code + session.restoreFailureMessage = action.payload.message + } + }, + + markSessionLost(state, action: PayloadAction<SessionMutationPayload>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].lost = true + }, + + removeSession(state, action: PayloadAction<SessionMutationPayload>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + delete state.sessions[key] + }, + + createFailed(state, action: PayloadAction<{ requestId: string } & PendingCreateFailure>) { + state.pendingCreateFailures[action.payload.requestId] = { + code: action.payload.code, + message: action.payload.message, + retryable: action.payload.retryable, + } + }, + + clearPendingCreateFailure(state, action: PayloadAction<{ requestId: string }>) { + delete state.pendingCreateFailures[action.payload.requestId] + }, + + restoreRetryRequested(state, action: PayloadAction<SessionMutationPayload>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + const session = state.sessions[key] + resetHydratedTimelineState(session) + session.restoreRetryCount = (session.restoreRetryCount ?? 0) + 1 + }, + + timelineLoadStarted(state, action: PayloadAction<SessionMutationPayload>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].timelineLoading = true + state.sessions[key].timelineError = undefined + }, + + timelinePageReceived(state, action: PayloadAction<SessionMutationPayload & { + turns: FreshAgentSessionState['timelineItems'] + nextCursor?: string | null + revision?: number + }>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + const session = state.sessions[key] + session.timelineLoading = false + session.historyLoaded = true + session.timelineItems = action.payload.turns + session.nextTimelineCursor = action.payload.nextCursor + session.timelineRevision = action.payload.revision ?? session.timelineRevision + }, + + timelineLoadFailed(state, action: PayloadAction<SessionMutationPayload & { message: string }>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + const session = state.sessions[key] + session.timelineLoading = false + session.timelineError = action.payload.message + }, + + turnBodyReceived(state, action: PayloadAction<SessionMutationPayload & { turn: FreshAgentSessionState['timelineItems'][number] }>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].timelineBodies[action.payload.turn.turnId] = action.payload.turn + }, + + turnResult() {}, + addUserMessage() {}, + addAssistantMessage() {}, + setStreaming() {}, + appendStreamDelta() {}, + clearStreaming() {}, + clearPendingCreateFailureForSession() {}, + sessionExited(state, action: PayloadAction<SessionMutationPayload>) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].status = 'exited' + }, + }, +}) + +export const { + addAssistantMessage, + addPermissionRequest, + addQuestionRequest, + addUserMessage, + appendStreamDelta, + clearPendingCreate, + clearPendingCreateFailure, + clearPendingCreateFailureForSession, + clearStreaming, + createFailed, + freshAgentSnapshotReceived, + markSessionLost, + registerPendingCreate, + removePermission, + removeQuestion, + removeSession, + restoreRetryRequested, + sessionCreated, + sessionError, + sessionExited, + sessionInit, + sessionMetadataReceived, + sessionSnapshotReceived, + setAvailableModels, + setSessionStatus, + setStreaming, + timelineLoadFailed, + timelineLoadStarted, + timelinePageReceived, + turnBodyReceived, + turnResult, +} = freshAgentSlice.actions + +export default freshAgentSlice.reducer diff --git a/src/store/freshAgentThunks.ts b/src/store/freshAgentThunks.ts new file mode 100644 index 000000000..fe443a33d --- /dev/null +++ b/src/store/freshAgentThunks.ts @@ -0,0 +1,100 @@ +import { createAsyncThunk } from '@reduxjs/toolkit' +import { getFreshAgentTurnBody, getFreshAgentTurnPage } from '@/lib/api' +import { + timelineLoadFailed, + timelineLoadStarted, + timelinePageReceived, + turnBodyReceived, +} from './freshAgentSlice' +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' + +type FreshAgentThreadThunkLocator = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId: string +} + +const inFlightControllers = new Set<AbortController>() + +export function _resetFreshAgentThunkControllers(): void { + for (const controller of inFlightControllers) { + controller.abort() + } + inFlightControllers.clear() +} + +export const loadFreshAgentTimelineWindow = createAsyncThunk( + 'freshAgent/loadTimelineWindow', + async ( + input: FreshAgentThreadThunkLocator & { + revision: number + cursor?: string + limit?: number + includeBodies?: boolean + }, + { dispatch }, + ) => { + const controller = new AbortController() + inFlightControllers.add(controller) + dispatch(timelineLoadStarted(input)) + try { + const page = await getFreshAgentTurnPage( + input.sessionType, + input.provider, + input.sessionId, + { + revision: input.revision, + cursor: input.cursor, + limit: input.limit, + includeBodies: input.includeBodies, + signal: controller.signal, + }, + ) + dispatch(timelinePageReceived({ + ...input, + turns: page.turns, + nextCursor: page.nextCursor, + revision: page.revision, + })) + return page + } catch (error) { + dispatch(timelineLoadFailed({ + ...input, + message: error instanceof Error ? error.message : 'Failed to load fresh-agent timeline', + })) + throw error + } finally { + inFlightControllers.delete(controller) + } + }, +) + +export const loadFreshAgentTurnBody = createAsyncThunk( + 'freshAgent/loadTurnBody', + async ( + input: FreshAgentThreadThunkLocator & { + turnId: string + revision: number + }, + { dispatch }, + ) => { + const controller = new AbortController() + inFlightControllers.add(controller) + try { + const turn = await getFreshAgentTurnBody( + input.sessionType, + input.provider, + input.sessionId, + input.turnId, + { + revision: input.revision, + signal: controller.signal, + }, + ) + dispatch(turnBodyReceived({ ...input, turn })) + return turn + } finally { + inFlightControllers.delete(controller) + } + }, +) diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts new file mode 100644 index 000000000..d0ddd6f4b --- /dev/null +++ b/src/store/freshAgentTypes.ts @@ -0,0 +1,91 @@ +import type { + FreshAgentRuntimeProvider, + FreshAgentSessionType, +} from '@shared/fresh-agent' +import type { + FreshAgentPendingApproval, + FreshAgentPendingQuestion, + FreshAgentRequestId, + FreshAgentSnapshot, + FreshAgentTurn, +} from '@shared/fresh-agent-contract' + +export type { FreshAgentRequestId } +export type FreshAgentPermissionRequest = FreshAgentPendingApproval +export type FreshAgentQuestionRequest = FreshAgentPendingQuestion +export type FreshAgentTimelineItem = FreshAgentTurn +export type FreshAgentTimelineTurn = FreshAgentTurn +export type FreshAgentContentBlock = FreshAgentTurn['items'][number] +export type FreshAgentMessage = FreshAgentTurn + +export type FreshAgentSessionStatus = + | 'creating' + | 'starting' + | 'connected' + | 'running' + | 'idle' + | 'compacting' + | 'exited' + +export type FreshAgentSessionLocator = { + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId: string +} + +export type PendingCreateFailure = { + code: string + message: string + retryable?: boolean +} + +export type FreshAgentPendingCreate = { + sessionId?: string + sessionKey?: string + sessionType?: FreshAgentSessionType + provider?: FreshAgentRuntimeProvider + expectsHistoryHydration: boolean +} + +export type FreshAgentSessionState = FreshAgentSessionLocator & { + sessionKey: string + threadId: string + status: FreshAgentSessionStatus + snapshot?: FreshAgentSnapshot + latestTurnId?: string | null + timelineSessionId?: string + timelineRevision?: number + cliSessionId?: string + cwd?: string + model?: string + tools?: Array<{ name: string }> + turns: FreshAgentTurn[] + timelineItems: FreshAgentTurn[] + timelineBodies: Record<string, FreshAgentTurn> + nextTimelineCursor?: string | null + timelineLoading?: boolean + timelineError?: string + streamingText: string + streamingActive: boolean + pendingPermissions: Record<string, FreshAgentPermissionRequest> + pendingQuestions: Record<string, FreshAgentQuestionRequest> + totalCostUsd: number + totalInputTokens: number + totalOutputTokens: number + lastError?: string + historyLoaded?: boolean + awaitingDurableHistory?: boolean + lost?: boolean + restoreRetryCount?: number + restoreFailureCode?: string + restoreFailureMessage?: string + snapshotRefreshRequestId?: number + restoreHydrationRequestId?: number +} + +export type FreshAgentState = { + sessions: Record<string, FreshAgentSessionState> + pendingCreates: Record<string, FreshAgentPendingCreate> + pendingCreateFailures: Record<string, PendingCreateFailure> + availableModels: Array<{ value: string; displayName: string; description: string }> +} diff --git a/src/store/paneTreeValidation.ts b/src/store/paneTreeValidation.ts index 2055ae2ab..5efbe819b 100644 --- a/src/store/paneTreeValidation.ts +++ b/src/store/paneTreeValidation.ts @@ -1,4 +1,5 @@ import { isAgentChatModelSelection, normalizeAgentChatEffortOverride, type PaneNode } from './paneTypes' +import { CodexDurabilityRefSchema } from '@shared/codex-durability' function isRecord(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' @@ -25,6 +26,10 @@ function isRestoreErrorShape(value: unknown): boolean { && typeof (value as any).reason === 'string' } +function isCodexDurabilityShape(value: unknown): boolean { + return value === undefined || CodexDurabilityRefSchema.safeParse(value).success +} + function isPaneContentShape(content: unknown): boolean { if (!isRecord(content) || typeof content.kind !== 'string') { return false @@ -39,6 +44,7 @@ function isPaneContentShape(content: unknown): boolean { && isOptionalString(content.shell) && isOptionalString(content.resumeSessionId) && isSessionRefShape(content.sessionRef) + && isCodexDurabilityShape(content.codexDurability) && isRestoreErrorShape(content.restoreError) && isOptionalString(content.initialCwd) case 'browser': @@ -53,6 +59,38 @@ function isPaneContentShape(content: unknown): boolean { && (content.viewMode === 'source' || content.viewMode === 'preview') case 'picker': return true + case 'fresh-agent': { + const isFreshAgentEffortValid = content.provider === 'claude' + ? (content.effort === undefined || (typeof content.effort === 'string' && content.effort.length > 0)) + : (content.effort === undefined + || content.effort === 'none' || content.effort === 'minimal' || content.effort === 'low' + || content.effort === 'medium' || content.effort === 'high' || content.effort === 'xhigh' + || content.effort === 'max') + const hasSessionRef = content.sessionRef !== undefined + && (typeof content.sessionRef === 'object' || !!(content.sessionRef as object)) + const hasRestoreError = content.restoreError !== undefined + return typeof content.sessionType === 'string' + && typeof content.provider === 'string' + && typeof content.createRequestId === 'string' + && typeof content.status === 'string' + && isOptionalString(content.sessionId) + && isOptionalString(content.resumeSessionId) + && isOptionalString(content.initialCwd) + && isOptionalString(content.model) + && isOptionalString(content.permissionMode) + && (content.modelSelection === undefined || isAgentChatModelSelection(content.modelSelection)) + && (content.sandbox === undefined + || content.sandbox === 'read-only' + || content.sandbox === 'workspace-write' + || content.sandbox === 'danger-full-access') + && isFreshAgentEffortValid + && isSessionRefShape(content.sessionRef) + && isRestoreErrorShape(content.restoreError) + && !(hasSessionRef && hasRestoreError) + && (content.plugins === undefined + || (Array.isArray(content.plugins) && content.plugins.every((plugin) => typeof plugin === 'string'))) + && (content.settingsDismissed === undefined || typeof content.settingsDismissed === 'boolean') + } case 'agent-chat': return typeof content.provider === 'string' && typeof content.createRequestId === 'string' diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 122aff019..fbf2ad46f 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -6,6 +6,8 @@ import { } from '@shared/agent-chat-capabilities' import type { SessionLocator as SharedSessionLocator } from '@shared/ws-protocol' import type { RestoreError } from '@shared/session-contract' +import type { CodexDurabilityRef } from '@shared/codex-durability' +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' export type SessionLocator = SharedSessionLocator @@ -60,6 +62,8 @@ export type TerminalPaneContent = { resumeSessionId?: string /** Portable session reference for cross-device tab snapshots */ sessionRef?: SessionLocator + /** Non-canonical Codex restore durability state and proof metadata. */ + codexDurability?: CodexDurabilityRef /** Runtime-only server locality for same-server matching; never part of canonical durable identity. */ serverInstanceId?: string /** Explicit restore failure when no canonical durable target exists. */ @@ -115,6 +119,30 @@ export type AgentChatCreateError = { retryable?: boolean } +export type FreshAgentPaneContent = { + kind: 'fresh-agent' + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId?: string + createRequestId: string + status: SdkSessionStatus + resumeSessionId?: string + sessionRef?: SessionLocator + /** Runtime-only server locality for same-server matching; never part of canonical durable identity. */ + serverInstanceId?: string + /** Explicit restore failure when no canonical durable target exists. */ + restoreError?: RestoreError + initialCwd?: string + createError?: AgentChatCreateError + modelSelection?: AgentChatModelSelection + model?: string + permissionMode?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + effort?: string + plugins?: string[] + settingsDismissed?: boolean +} + /** * Agent chat pane — rich chat UI powered by a configurable provider. */ @@ -165,7 +193,7 @@ export type ExtensionPaneContent = { * Union type for all pane content types. */ export type PaneContent = TerminalPaneContent | BrowserPaneContent | EditorPaneContent - | PickerPaneContent | AgentChatPaneContent | ExtensionPaneContent + | PickerPaneContent | FreshAgentPaneContent | AgentChatPaneContent | ExtensionPaneContent /** * Input type for creating terminal panes. @@ -195,6 +223,11 @@ export type AgentChatPaneInput = Omit<AgentChatPaneContent, 'createRequestId' | status?: SdkSessionStatus } +export type FreshAgentPaneInput = Omit<FreshAgentPaneContent, 'createRequestId' | 'status'> & { + createRequestId?: string + status?: SdkSessionStatus +} + /** * Input type for extension panes. * Extension content needs no normalization — passes through unchanged. @@ -202,7 +235,7 @@ export type AgentChatPaneInput = Omit<AgentChatPaneContent, 'createRequestId' | export type ExtensionPaneInput = ExtensionPaneContent export type PaneContentInput = TerminalPaneInput | BrowserPaneInput | EditorPaneInput - | PickerPaneContent | AgentChatPaneInput | ExtensionPaneInput + | PickerPaneContent | FreshAgentPaneInput | AgentChatPaneInput | ExtensionPaneInput export type PaneRefreshTarget = | { kind: 'terminal'; createRequestId: string } @@ -213,6 +246,12 @@ export interface PaneRefreshRequest { target: PaneRefreshTarget } +export type RestoreFallbackAttempt = { + staleTerminalId: string + requestId: string + reason: 'dead_live_handle_without_session_ref' +} + /** * Recursive tree structure for pane layouts. * A leaf is a single pane with content. @@ -254,6 +293,11 @@ export interface PanesState { * Must never be persisted. */ refreshRequestsByPane: Record<string, Record<string, PaneRefreshRequest>> + /** + * Ephemeral one-shot fresh recovery guards keyed by tab and pane id. + * Must never be persisted. + */ + restoreFallbackAttemptsByPane: Record<string, Record<string, RestoreFallbackAttempt>> } /** diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 09329ac67..c7985d4c3 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -8,6 +8,7 @@ import { type PaneContentInput, type PaneNode, type PaneRefreshRequest, + type RestoreFallbackAttempt, } from './paneTypes' import { derivePaneTitle } from '@/lib/derivePaneTitle' import { matchesDerivedPaneTitle } from '@/lib/pane-title' @@ -18,7 +19,9 @@ import { hasPaneTreeShape, isWellFormedPaneTree } from './paneTreeValidation.js' import { createLogger } from '@/lib/client-logger' import { patchBrowserPreferencesRecord } from '@/lib/browser-preferences' import { shouldPreserveLocalCanonicalResumeSessionId } from './persistControl' -import { RestoreErrorSchema, sanitizeSessionRef } from '@shared/session-contract' +import { RestoreErrorSchema, migrateLegacyAgentChatDurableState, sanitizeSessionRef } from '@shared/session-contract' +import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' +import { migrateLegacyFreshAgentContent } from '@shared/fresh-agent' const log = createLogger('PanesSlice') @@ -29,7 +32,7 @@ type HydratePanesMeta = { } function buildPreservedSessionRef( - localContent: Extract<PaneContent, { kind: 'terminal' | 'agent-chat' }>, + localContent: Extract<PaneContent, { kind: 'terminal' | 'agent-chat' | 'fresh-agent' }>, _preservedResumeSessionId?: string, ) { return sanitizeSessionRef(localContent.sessionRef) @@ -42,6 +45,7 @@ function normalizePaneContent( input: PaneContentInput | PaneContent, previous?: PaneContent, ): PaneContent { + input = migrateLegacyFreshAgentContent(input as any) as PaneContentInput | PaneContent if (input.kind === 'terminal') { const mode = typeof input.mode === 'string' ? input.mode : 'shell' const inputResumeSessionId = typeof input.resumeSessionId === 'string' @@ -49,6 +53,7 @@ function normalizePaneContent( : undefined const resumeSessionId = inputResumeSessionId const sessionRef = sanitizeSessionRef(input.sessionRef) + const codexDurability = sanitizeCodexDurabilityRef(input.codexDurability) const restoreError = RestoreErrorSchema.safeParse((input as { restoreError?: unknown }).restoreError) return { kind: 'terminal', @@ -61,6 +66,7 @@ function normalizePaneContent( shell: typeof input.shell === 'string' ? input.shell : 'system', resumeSessionId, ...(sessionRef ? { sessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), serverInstanceId: typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined, ...(restoreError.success ? { restoreError: restoreError.data } : {}), initialCwd: typeof input.initialCwd === 'string' ? input.initialCwd : undefined, @@ -79,6 +85,42 @@ function normalizePaneContent( devToolsOpen: typeof input.devToolsOpen === 'boolean' ? input.devToolsOpen : false, } } + if (input.kind === 'fresh-agent') { + const durableState = input.provider === 'claude' + ? migrateLegacyAgentChatDurableState({ + sessionRef: input.sessionRef, + resumeSessionId: typeof input.resumeSessionId === 'string' ? input.resumeSessionId : undefined, + }) + : { sessionRef: sanitizeSessionRef(input.sessionRef) } + const sessionRef = durableState.sessionRef + const restoreError = RestoreErrorSchema.safeParse((input as { restoreError?: unknown }).restoreError) + return { + kind: 'fresh-agent', + sessionType: input.sessionType, + provider: input.provider, + sessionId: input.sessionId, + createRequestId: input.createRequestId || nanoid(), + status: input.status || 'creating', + resumeSessionId: input.resumeSessionId, + ...(sessionRef ? { sessionRef } : {}), + serverInstanceId: typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined, + ...(restoreError.success + ? { restoreError: restoreError.data } + : ('restoreError' in durableState && durableState.restoreError ? { restoreError: durableState.restoreError } : {})), + initialCwd: input.initialCwd, + createError: input.createError, + modelSelection: normalizeAgentChatModelSelection( + (input as { modelSelection?: unknown }).modelSelection, + (input as { model?: unknown }).model, + ), + model: input.model, + permissionMode: input.permissionMode, + sandbox: input.sandbox, + effort: normalizeAgentChatEffortOverride(input.effort), + plugins: input.plugins, + settingsDismissed: input.settingsDismissed, + } + } if (input.kind === 'agent-chat') { const sessionRef = sanitizeSessionRef(input.sessionRef) const restoreError = RestoreErrorSchema.safeParse((input as { restoreError?: unknown }).restoreError) @@ -111,12 +153,14 @@ function normalizePaneContent( return input } -function shouldPreferLocalAgentChatPaneDuringHydration( +function shouldPreferLocalAgentPaneDuringHydration( localContent: PaneContent, incomingContent: PaneContent, meta: HydratePanesMeta | undefined, ): boolean { - if (localContent.kind !== 'agent-chat' || incomingContent.kind !== 'agent-chat') { + const localIsAgentPane = localContent.kind === 'agent-chat' || localContent.kind === 'fresh-agent' + const incomingIsAgentPane = incomingContent.kind === 'agent-chat' || incomingContent.kind === 'fresh-agent' + if (!localIsAgentPane || !incomingIsAgentPane || localContent.kind !== incomingContent.kind) { return false } @@ -157,6 +201,7 @@ function cleanOrphanedLayouts(state: PanesState): PanesState { const nextPaneTitles = { ...state.paneTitles } const nextPaneTitleSetByUser = { ...state.paneTitleSetByUser } const nextRefreshRequestsByPane = { ...state.refreshRequestsByPane } + const nextRestoreFallbackAttemptsByPane = { ...state.restoreFallbackAttemptsByPane } for (const tabId of orphaned) { delete nextLayouts[tabId] @@ -164,6 +209,7 @@ function cleanOrphanedLayouts(state: PanesState): PanesState { delete nextPaneTitles[tabId] delete nextPaneTitleSetByUser[tabId] delete nextRefreshRequestsByPane[tabId] + delete nextRestoreFallbackAttemptsByPane[tabId] } return { @@ -173,6 +219,7 @@ function cleanOrphanedLayouts(state: PanesState): PanesState { paneTitles: nextPaneTitles, paneTitleSetByUser: nextPaneTitleSetByUser, refreshRequestsByPane: nextRefreshRequestsByPane, + restoreFallbackAttemptsByPane: nextRestoreFallbackAttemptsByPane, } } catch { return state @@ -272,6 +319,7 @@ function loadInitialPanesState(): PanesState { renameRequestPaneId: null, zoomedPane: {}, refreshRequestsByPane: {}, + restoreFallbackAttemptsByPane: {}, } try { @@ -288,6 +336,7 @@ function loadInitialPanesState(): PanesState { renameRequestPaneId: null, zoomedPane: {}, refreshRequestsByPane: {}, + restoreFallbackAttemptsByPane: {}, } state = cleanOrphanedLayouts(state) state = migrateLegacyAgentChatDisplaySettings(state) @@ -506,6 +555,16 @@ function clearPaneRefreshRequest(state: PanesState, tabId: string, paneId: strin } } +function clearRestoreFallbackAttemptForPane(state: PanesState, tabId: string, paneId: string) { + const tabAttempts = state.restoreFallbackAttemptsByPane?.[tabId] + if (!tabAttempts?.[paneId]) return + + delete tabAttempts[paneId] + if (Object.keys(tabAttempts).length === 0) { + delete state.restoreFallbackAttemptsByPane?.[tabId] + } +} + function reconcileRefreshRequestsForTab(state: PanesState, tabId: string) { const tabRequests = state.refreshRequestsByPane?.[tabId] if (!tabRequests) return @@ -587,11 +646,14 @@ function mergeTerminalState( } } - // Agent-chat panes: prefer local sessionId and status when the local state + // Agent panes: prefer local sessionId and status when the local state // is more advanced. The persist debounce means incoming (from localStorage) // can be stale — e.g. status 'starting' when local has already reached 'connected'. - if (incoming.content?.kind === 'agent-chat' && local.content?.kind === 'agent-chat') { - if (shouldPreferLocalAgentChatPaneDuringHydration(local.content, incoming.content, meta)) { + if ( + (incoming.content?.kind === 'agent-chat' || incoming.content?.kind === 'fresh-agent') + && incoming.content?.kind === local.content?.kind + ) { + if (shouldPreferLocalAgentPaneDuringHydration(local.content, incoming.content, meta)) { return local } if (incoming.content.createRequestId === local.content.createRequestId) { @@ -746,6 +808,7 @@ export const panesSlice = createSlice({ state.activePane[tabId] = paneId state.paneTitles[tabId] = { [paneId]: derivePaneTitle(normalized) } reconcileRefreshRequestsForTab(state, tabId) + delete state.restoreFallbackAttemptsByPane?.[tabId] }, restoreLayout: ( @@ -763,6 +826,7 @@ export const panesSlice = createSlice({ state.paneTitleSetByUser[tabId] = paneTitleSetByUser } reconcileRefreshRequestsForTab(state, tabId) + delete state.restoreFallbackAttemptsByPane?.[tabId] }, resetLayout: ( @@ -780,6 +844,7 @@ export const panesSlice = createSlice({ state.activePane[tabId] = paneId state.paneTitles[tabId] = { [paneId]: derivePaneTitle(normalized) } reconcileRefreshRequestsForTab(state, tabId) + delete state.restoreFallbackAttemptsByPane?.[tabId] }, splitPane: ( @@ -1265,7 +1330,7 @@ export const panesSlice = createSlice({ function restartContent(node: PaneNode): PaneNode { if (node.type === 'leaf') { - if (node.id !== paneId || node.content.kind !== 'agent-chat') { + if (node.id !== paneId || (node.content.kind !== 'agent-chat' && node.content.kind !== 'fresh-agent')) { return node } return { @@ -1379,6 +1444,9 @@ export const panesSlice = createSlice({ if (state.refreshRequestsByPane) { delete state.refreshRequestsByPane[tabId] } + if (state.restoreFallbackAttemptsByPane) { + delete state.restoreFallbackAttemptsByPane[tabId] + } }, hydratePanes: (state, action: PayloadAction<PanesState>) => { @@ -1426,6 +1494,7 @@ export const panesSlice = createSlice({ state.renameRequestPaneId = null state.zoomedPane = {} state.refreshRequestsByPane = {} + state.restoreFallbackAttemptsByPane = {} }, updatePaneTitle: ( @@ -1510,16 +1579,29 @@ export const panesSlice = createSlice({ clearDeadTerminals: (state, action: PayloadAction<{ liveTerminalIds: string[] }>) => { const liveSet = new Set(action.payload.liveTerminalIds) - function clearDeadInNode(node: PaneNode): boolean { + function clearDeadInNode(node: PaneNode, tabId: string): boolean { if (node.type === 'leaf') { if ( node.content?.kind === 'terminal' && node.content.terminalId && !liveSet.has(node.content.terminalId) ) { + const staleTerminalId = node.content.terminalId + const nextRequestId = nanoid() node.content.terminalId = undefined node.content.status = 'creating' - node.content.createRequestId = nanoid() + node.content.createRequestId = nextRequestId + if (!sanitizeSessionRef(node.content.sessionRef)) { + if (!state.restoreFallbackAttemptsByPane) state.restoreFallbackAttemptsByPane = {} + if (!state.restoreFallbackAttemptsByPane[tabId]) state.restoreFallbackAttemptsByPane[tabId] = {} + state.restoreFallbackAttemptsByPane[tabId][node.id] = { + staleTerminalId, + requestId: nextRequestId, + reason: 'dead_live_handle_without_session_ref', + } + } else { + clearRestoreFallbackAttemptForPane(state, tabId, node.id) + } return true } return false @@ -1527,15 +1609,15 @@ export const panesSlice = createSlice({ if (node.type === 'split' && Array.isArray(node.children)) { let changed = false for (const child of node.children) { - if (clearDeadInNode(child)) changed = true + if (clearDeadInNode(child, tabId)) changed = true } return changed } return false } - for (const layout of Object.values(state.layouts)) { - clearDeadInNode(layout) + for (const [tabId, layout] of Object.entries(state.layouts)) { + clearDeadInNode(layout, tabId) } }, }, diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index d9011c749..5db80cc8b 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -6,11 +6,19 @@ import { nanoid } from 'nanoid' import { broadcastPersistedRaw } from './persistBroadcast' import { isWellFormedPaneTree } from './paneTreeValidation.js' import { PANES_SCHEMA_VERSION, LAYOUT_SCHEMA_VERSION, parsePersistedLayoutRaw } from './persistedState.js' -import { LAYOUT_STORAGE_KEY, PANES_STORAGE_KEY } from './storage-keys' +import { LAYOUT_STORAGE_KEY, PANES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys' import { createLogger } from '@/lib/client-logger' import { flushPersistedLayoutNow } from './persistControl' import { sanitizeSessionRef } from '@shared/session-contract' import { normalizeAgentChatEffortOverride, normalizeAgentChatModelSelection } from './paneTypes' +import { + loadPersistedTabRecency, + mergeTabRecencyStatesByMax, + prunePaneTabActivityToLiveTerminalPanes, + serializePersistableTabRecency, + type TabRecencyState, +} from './tabRecencySlice' +import { migrateLegacyFreshAgentContent } from '@shared/fresh-agent' const log = createLogger('PanesPersist') @@ -133,6 +141,7 @@ function migratePaneContent(content: any): any { if (!content || typeof content !== 'object') { return content } + content = migrateLegacyFreshAgentContent(content) if (content.kind === 'agent-chat') { const { model: _legacyModel, ...rest } = content return { @@ -141,6 +150,21 @@ function migratePaneContent(content: any): any { effort: normalizeAgentChatEffortOverride(content.effort), } } + if (content.kind === 'fresh-agent') { + const { model: legacyModel, modelSelection: legacyModelSelection, ...rest } = content + if (content.provider === 'codex') { + return { + ...rest, + ...(typeof legacyModel === 'string' ? { model: legacyModel } : {}), + effort: normalizeAgentChatEffortOverride(content.effort), + } + } + return { + ...rest, + modelSelection: normalizeAgentChatModelSelection(legacyModelSelection, legacyModel), + effort: normalizeAgentChatEffortOverride(content.effort), + } + } if (content.kind === 'browser') { return { ...content, @@ -176,17 +200,21 @@ function stripEditorContent(content: any): any { function stripTransientSessionFields(content: any): any { if (!content || typeof content !== 'object') return content - if (content.kind !== 'terminal' && content.kind !== 'agent-chat') return content + if (content.kind !== 'terminal' && content.kind !== 'agent-chat' && content.kind !== 'fresh-agent') return content const sessionRef = sanitizeSessionRef(content.sessionRef) const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, + sessionId: _sessionId, ...rest } = content return { ...rest, + ...(content.kind === 'fresh-agent' && !sessionRef && typeof content.serverInstanceId === 'string' && typeof content.sessionId === 'string' + ? { sessionId: content.sessionId } + : {}), ...(sessionRef ? { sessionRef } : {}), } } @@ -403,13 +431,16 @@ function migratePanesData(parsed: any): any | null { } type PersistState = { - tabs: TabsState - panes: PanesState + tabs?: TabsState + panes?: PanesState + tabRecency?: TabRecencyState } export const persistMiddleware: Middleware<{}, PersistState> = (store) => { let tabsDirty = false let panesDirty = false + let tabRecencyDirty = false + let tabRecencyPruneDirty = false let flushTimer: ReturnType<typeof setTimeout> | null = null const canUseStorage = () => typeof localStorage !== 'undefined' @@ -417,62 +448,88 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { const flush = () => { flushTimer = null if (!canUseStorage()) return - if (!tabsDirty && !panesDirty) return + if (!tabsDirty && !panesDirty && !tabRecencyDirty) return const state = store.getState() try { - // Prune tombstones older than 1 hour - const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000 - const tombstoneCutoff = Date.now() - TOMBSTONE_MAX_AGE_MS - const tombstones = (state.tabs.tombstones || []).filter((t: { deletedAt: number }) => t.deletedAt > tombstoneCutoff) - - const sanitizedLayouts: Record<string, any> = {} - if (state.panes?.layouts) { - for (const [tabId, node] of Object.entries(state.panes.layouts)) { - sanitizedLayouts[tabId] = stripEditorContentFromNode(node) + if (tabsDirty || panesDirty) { + // Prune tombstones older than 1 hour + const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000 + const tombstoneCutoff = Date.now() - TOMBSTONE_MAX_AGE_MS + const tombstones = (state.tabs?.tombstones || []).filter((t: { deletedAt: number }) => t.deletedAt > tombstoneCutoff) + + const sanitizedLayouts: Record<string, any> = {} + if (state.panes?.layouts) { + for (const [tabId, node] of Object.entries(state.panes.layouts)) { + sanitizedLayouts[tabId] = stripEditorContentFromNode(node) + } } - } - let persistablePanesSection: Record<string, any> = { - layouts: sanitizedLayouts, - version: PANES_SCHEMA_VERSION, - } - if (state.panes) { - const { - renameRequestTabId: _rrt, - renameRequestPaneId: _rrp, - zoomedPane: _zp, - refreshRequestsByPane: _rrbp, - ...persistablePanes - } = state.panes - persistablePanesSection = { - ...persistablePanes, + let persistablePanesSection: Record<string, any> = { layouts: sanitizedLayouts, version: PANES_SCHEMA_VERSION, } - } + if (state.panes) { + const { + renameRequestTabId: _rrt, + renameRequestPaneId: _rrp, + zoomedPane: _zp, + refreshRequestsByPane: _rrbp, + restoreFallbackAttemptsByPane: _rfabp, + ...persistablePanes + } = state.panes + persistablePanesSection = { + ...persistablePanes, + layouts: sanitizedLayouts, + version: PANES_SCHEMA_VERSION, + } + } - const layoutPayload = { - persistedAt: Date.now(), - version: LAYOUT_SCHEMA_VERSION, - tabs: { - activeTabId: state.tabs.activeTabId, - tabs: state.tabs.tabs.map(stripTabVolatileFields), - }, - panes: persistablePanesSection, - tombstones, + const layoutPayload = { + persistedAt: Date.now(), + version: LAYOUT_SCHEMA_VERSION, + tabs: { + activeTabId: state.tabs?.activeTabId ?? null, + tabs: (state.tabs?.tabs ?? []).map(stripTabVolatileFields), + }, + panes: persistablePanesSection, + tombstones, + } + + const raw = JSON.stringify(layoutPayload) + localStorage.setItem(LAYOUT_STORAGE_KEY, raw) + broadcastPersistedRaw(LAYOUT_STORAGE_KEY, raw) } - const raw = JSON.stringify(layoutPayload) - localStorage.setItem(LAYOUT_STORAGE_KEY, raw) - broadcastPersistedRaw(LAYOUT_STORAGE_KEY, raw) + if (tabRecencyDirty) { + const liveTabIds = new Set((state.tabs?.tabs ?? []).map((tab) => tab.id)) + const nextTabRecency = serializePersistableTabRecency( + state.tabRecency ?? { paneLastInputAt: {} }, + tabRecencyPruneDirty ? state.panes?.layouts ?? {} : undefined, + tabRecencyPruneDirty ? liveTabIds : undefined, + ) + const persistedTabRecency = tabRecencyPruneDirty + ? nextTabRecency + : mergeTabRecencyStatesByMax( + loadPersistedTabRecency(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)), + { paneLastInputAt: nextTabRecency.paneLastInputAt }, + ) + const rawTabRecency = JSON.stringify({ + version: 1, + paneLastInputAt: persistedTabRecency.paneLastInputAt, + }) + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, rawTabRecency) + broadcastPersistedRaw(TAB_RECENCY_STORAGE_KEY, rawTabRecency) + } } catch (err) { log.error('Failed to save to localStorage:', err) } tabsDirty = false panesDirty = false + tabRecencyDirty = false + tabRecencyPruneDirty = false } const scheduleFlush = () => { @@ -491,7 +548,9 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { registerFlushCallback(flushNow) return (next) => (action) => { + const previousState = store.getState() const result = next(action) + const state = store.getState() const a = action as any if (a?.type === flushPersistedLayoutNow.type) { @@ -503,14 +562,25 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { } if (typeof a?.type === 'string') { - if (a.type.startsWith('tabs/')) { + const tabsChanged = state.tabs !== previousState.tabs + const panesChanged = state.panes !== previousState.panes + const tabRecencyChanged = state.tabRecency !== previousState.tabRecency + + if (a.type.startsWith('tabs/') && tabsChanged) { tabsDirty = true scheduleFlush() } - if (a.type.startsWith('panes/')) { + if (a.type.startsWith('panes/') && panesChanged) { panesDirty = true scheduleFlush() } + if (a.type.startsWith('tabRecency/') && tabRecencyChanged) { + tabRecencyDirty = true + if (a.type === prunePaneTabActivityToLiveTerminalPanes.type) { + tabRecencyPruneDirty = true + } + scheduleFlush() + } } return result diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 90eabefc6..3757b74a2 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -6,6 +6,8 @@ import { migrateLegacyTerminalDurableState, sanitizeSessionRef, } from '@shared/session-contract' +import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' +import { migrateLegacyFreshAgentContent } from '@shared/fresh-agent' export { LAYOUT_STORAGE_KEY, TABS_STORAGE_KEY, PANES_STORAGE_KEY } @@ -95,11 +97,13 @@ function normalizePersistedTab(tab: Record<string, unknown>): PersistedTab { sessionRef: tab.sessionRef, resumeSessionId: typeof tab.resumeSessionId === 'string' ? tab.resumeSessionId : undefined, }) + const codexDurability = sanitizeCodexDurabilityRef(tab.codexDurability) const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, ...rest } = tab return { ...rest, ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), } as PersistedTab } @@ -164,6 +168,7 @@ function normalizeTerminalContent(content: Record<string, unknown>): Record<stri ? content.restoreError : undefined const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content + const codexDurability = sanitizeCodexDurabilityRef(content.codexDurability) const isLegacyRecoveryFailed = ( rest.kind === 'terminal' && rest.mode === 'codex' @@ -180,6 +185,7 @@ function normalizeTerminalContent(content: Record<string, unknown>): Record<stri return { ...normalizedRuntime, ...(normalizedSessionRef ? { sessionRef: normalizedSessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), ...(normalizedRestoreError ? { restoreError: normalizedRestoreError } : {}), @@ -212,17 +218,47 @@ function normalizeAgentChatContent(content: Record<string, unknown>): Record<str } } +function normalizeFreshAgentContent(content: Record<string, unknown>): Record<string, unknown> { + const durableState = content.provider === 'claude' + ? migrateLegacyAgentChatDurableState({ + sessionRef: content.sessionRef, + cliSessionId: typeof content.cliSessionId === 'string' ? content.cliSessionId : undefined, + timelineSessionId: typeof content.timelineSessionId === 'string' ? content.timelineSessionId : undefined, + resumeSessionId: typeof content.resumeSessionId === 'string' ? content.resumeSessionId : undefined, + }) + : { sessionRef: sanitizeSessionRef(content.sessionRef) } + const existingRestoreError = ( + content.restoreError + && typeof content.restoreError === 'object' + && (content.restoreError as any).code === 'RESTORE_UNAVAILABLE' + && typeof (content.restoreError as any).reason === 'string' + ) + ? content.restoreError + : undefined + const { sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content + + return { + ...rest, + ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), + ...(('restoreError' in durableState && durableState.restoreError) || existingRestoreError + ? { restoreError: ('restoreError' in durableState && durableState.restoreError) || existingRestoreError } + : {}), + } +} + function normalizePersistedNode(node: unknown): unknown { if (!node || typeof node !== 'object') return node const candidate = node as Record<string, unknown> if (candidate.type === 'leaf' && candidate.content && typeof candidate.content === 'object') { - const content = candidate.content as Record<string, unknown> + const content = migrateLegacyFreshAgentContent(candidate.content as Record<string, unknown>) as Record<string, unknown> let nextContent = content if (content.kind === 'terminal') { nextContent = normalizeTerminalContent(content) } else if (content.kind === 'agent-chat') { nextContent = normalizeAgentChatContent(content) + } else if (content.kind === 'fresh-agent') { + nextContent = normalizeFreshAgentContent(content) } else if ('sessionRef' in content) { const sanitizedSessionRef = sanitizeSessionRef(content.sessionRef) const { sessionRef: _legacySessionRef, ...rest } = content diff --git a/src/store/selectors/paneTerminalSelectors.ts b/src/store/selectors/paneTerminalSelectors.ts index 4e76d5ca3..d522d9f0f 100644 --- a/src/store/selectors/paneTerminalSelectors.ts +++ b/src/store/selectors/paneTerminalSelectors.ts @@ -49,6 +49,38 @@ export function selectTabIdByTerminalId(state: RootState, terminalId: string): s return undefined } +export function selectTabPaneByTerminalId( + state: RootState, + terminalId: string, +): { tabId: string; paneId: string } | undefined { + const activeTabId = state.tabs.activeTabId + if (activeTabId) { + const activeLayout = state.panes.layouts[activeTabId] + if (activeLayout) { + const activePaneId = findPaneIdByTerminalId(activeLayout, terminalId) + if (activePaneId) { + return { tabId: activeTabId, paneId: activePaneId } + } + } + } + + for (const [tabId, layout] of Object.entries(state.panes.layouts)) { + if (tabId === activeTabId) continue + const paneId = findPaneIdByTerminalId(layout, terminalId) + if (paneId) { + return { tabId, paneId } + } + } + return undefined +} + +export function selectPaneLocationByTerminalId( + state: RootState, + terminalId: string, +): { tabId: string; paneId: string } | undefined { + return selectTabPaneByTerminalId(state, terminalId) +} + function findFirstTerminalId(node: PaneNode): string | undefined { if (node.type === 'leaf') { return node.content.kind === 'terminal' ? node.content.terminalId : undefined @@ -63,3 +95,13 @@ function nodeContainsTerminalId(node: PaneNode, terminalId: string): boolean { return nodeContainsTerminalId(node.children[0], terminalId) || nodeContainsTerminalId(node.children[1], terminalId) } + +function findPaneIdByTerminalId(node: PaneNode, terminalId: string): string | undefined { + if (node.type === 'leaf') { + return node.content.kind === 'terminal' && node.content.terminalId === terminalId + ? node.id + : undefined + } + return findPaneIdByTerminalId(node.children[0], terminalId) + ?? findPaneIdByTerminalId(node.children[1], terminalId) +} diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index 364d3aea6..56b41d363 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -4,9 +4,13 @@ import type { BackgroundTerminal, CodingCliProviderName, WorktreeGrouping } from import { isValidClaudeSessionId } from '@/lib/claude-session-id' import { collectSessionRefsFromTabs } from '@/lib/session-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import { getSessionMetadata } from '@/lib/session-metadata' +import { getProviderLabel, isNonShellMode } from '@/lib/coding-cli-utils' import type { SessionListMetadata } from '../types' import { getLeafDirectoryName, matchTitleTierMetadata } from '../../../shared/session-title-search.js' +import { deriveTabRecencyAt } from '@/lib/tab-recency' +import type { CodexDurabilityRef, CodexDurabilityStateName } from '../../../shared/codex-durability.js' export interface SidebarSessionItem { id: string @@ -30,14 +34,21 @@ export interface SidebarSessionItem { firstUserMessage?: string hasTitle: boolean isFallback?: true + liveTerminalOnly?: boolean + isRestorable?: boolean + codexDurability?: CodexDurabilityRef + codexDurabilityState?: CodexDurabilityStateName + codexDurabilityReason?: string } const EMPTY_ACTIVITY: Record<string, number> = {} const EMPTY_STRINGS: string[] = [] +const EMPTY_PANE_LAST_INPUT_AT: Record<string, number | undefined> = {} const selectProjects = (state: RootState) => state.sessions.windows?.sidebar?.projects ?? state.sessions.projects const selectTabs = (state: RootState) => state.tabs.tabs const selectPanes = (state: RootState) => state.panes +const selectPaneLastInputAt = (state: RootState) => state.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT const selectSortMode = (state: RootState) => state.settings.settings.sidebar?.sortMode || 'activity' const selectSessionActivityForSort = (state: RootState) => { const sortMode = state.settings.settings.sidebar?.sortMode || 'activity' @@ -60,6 +71,64 @@ function getProjectName(projectPath: string): string { return getLeafDirectoryName(projectPath) ?? projectPath } +function liveTerminalSessionId(terminalId: string): string { + return `terminal:${terminalId}` +} + +function collectTerminalPaneTitles( + tabs: RootState['tabs']['tabs'], + panes: RootState['panes'], +): Map<string, { title?: string; hasTab: boolean }> { + const result = new Map<string, { title?: string; hasTab: boolean }>() + const paneTitles = panes?.paneTitles ?? {} + + const visit = ( + node: RootState['panes']['layouts'][string], + tab: RootState['tabs']['tabs'][number], + ) => { + if (!node) return + if (node.type !== 'leaf') { + visit(node.children[0], tab) + visit(node.children[1], tab) + return + } + if (node.content.kind !== 'terminal' || !node.content.terminalId) return + result.set(node.content.terminalId, { + title: paneTitles?.[tab.id]?.[node.id] || tab.title, + hasTab: true, + }) + } + + for (const tab of tabs || []) { + const layout = panes.layouts?.[tab.id] + if (layout) visit(layout, tab) + } + + return result +} + +function getCodexDurabilitySessionId(durability?: CodexDurabilityRef): string | undefined { + return durability?.durableThreadId ?? durability?.candidate?.candidateThreadId +} + +function isCodexDurabilityRestorable(durability?: CodexDurabilityRef): boolean { + return Boolean(durability?.state === 'durable' && durability.durableThreadId) +} + +function getCodexDurabilityReason(durability?: CodexDurabilityRef): string | undefined { + return durability?.nonRestorableReason ?? durability?.lastProofFailure?.message ?? durability?.lastProofFailure?.reason +} + +type RunningSessionInfo = { + terminalId: string + createdAt: number + allTerminalIds: string[] + isRestorable?: boolean + codexDurability?: CodexDurabilityRef + codexDurabilityState?: CodexDurabilityStateName + codexDurabilityReason?: string +} + export function buildSessionItems( projects: RootState['sessions']['projects'], tabs: RootState['tabs']['tabs'], @@ -67,24 +136,62 @@ export function buildSessionItems( terminals: BackgroundTerminal[], sessionActivity: Record<string, number>, worktreeGrouping: WorktreeGrouping = 'repo', + paneLastInputAt: Record<string, number | undefined> = EMPTY_PANE_LAST_INPUT_AT, ): SidebarSessionItem[] { const items: SidebarSessionItem[] = [] const itemsByKey = new Map<string, SidebarSessionItem>() - const runningSessionMap = new Map<string, { terminalId: string; createdAt: number; allTerminalIds: string[] }>() + const runningSessionMap = new Map<string, RunningSessionInfo>() const tabSessionMap = new Map<string, { hasTab: boolean }>() + const terminalPaneTitles = collectTerminalPaneTitles(tabs, panes) for (const terminal of terminals || []) { - if (terminal.status === 'running' && terminal.sessionRef) { - const sessionKey = `${terminal.sessionRef.provider}:${terminal.sessionRef.sessionId}` + if (terminal.status === 'running') { + const codexDurabilitySessionId = terminal.mode === 'codex' + ? getCodexDurabilitySessionId(terminal.codexDurability) + : undefined + const sessionRef = terminal.sessionRef ?? ( + codexDurabilitySessionId + ? { provider: 'codex' as const, sessionId: codexDurabilitySessionId } + : undefined + ) + if (!sessionRef) continue + + const sessionKey = `${sessionRef.provider}:${sessionRef.sessionId}` + const isRestorable = sessionRef === terminal.sessionRef + ? true + : isCodexDurabilityRestorable(terminal.codexDurability) + const codexDurability = terminal.mode === 'codex' + ? terminal.codexDurability + : undefined + const codexDurabilityState = terminal.mode === 'codex' + ? terminal.codexDurability?.state + : undefined + const codexDurabilityReason = terminal.mode === 'codex' + ? getCodexDurabilityReason(terminal.codexDurability) + : undefined const existing = runningSessionMap.get(sessionKey) if (existing) { existing.allTerminalIds.push(terminal.terminalId) + existing.isRestorable = existing.isRestorable || isRestorable + existing.codexDurability = existing.codexDurability ?? codexDurability + if (!existing.codexDurabilityState || codexDurabilityState === 'durable') { + existing.codexDurabilityState = codexDurabilityState + } + existing.codexDurabilityReason = existing.codexDurabilityReason ?? codexDurabilityReason if (terminal.createdAt < existing.createdAt) { existing.terminalId = terminal.terminalId existing.createdAt = terminal.createdAt } } else { - runningSessionMap.set(sessionKey, { terminalId: terminal.terminalId, createdAt: terminal.createdAt, allTerminalIds: [terminal.terminalId] }) + runningSessionMap.set(sessionKey, { + terminalId: terminal.terminalId, + createdAt: terminal.createdAt, + allTerminalIds: [terminal.terminalId], + isRestorable, + codexDurability, + codexDurabilityState, + codexDurabilityReason, + }) } } } @@ -101,7 +208,8 @@ export function buildSessionItems( const provider = session.provider || 'claude' const key = `${provider}:${session.sessionId}` const runningTerminal = runningSessionMap.get(key) - const runningTerminalId = runningTerminal?.terminalId + const serverRunningTerminalId = session.isRunning ? session.runningTerminalId : undefined + const runningTerminalId = runningTerminal?.terminalId ?? serverRunningTerminalId const runningTerminalIds = runningTerminal?.allTerminalIds const tabInfo = tabSessionMap.get(key) const ratchetedActivity = sessionActivity[key] @@ -131,6 +239,11 @@ export function buildSessionItems( isNonInteractive: session.isNonInteractive, firstUserMessage: session.firstUserMessage, isFallback: undefined, + liveTerminalOnly: session.liveTerminalOnly, + isRestorable: runningTerminal?.isRestorable, + codexDurability: runningTerminal?.codexDurability, + codexDurabilityState: runningTerminal?.codexDurabilityState, + codexDurabilityReason: runningTerminal?.codexDurabilityReason, } items.push(item) itemsByKey.set(key, item) @@ -147,11 +260,15 @@ export function buildSessionItems( cwd?: string timestamp?: number metadata?: SessionListMetadata + hasTab?: boolean + isRestorable?: boolean + codexDurability?: CodexDurabilityRef + codexDurabilityState?: CodexDurabilityStateName + codexDurabilityReason?: string }) => { const key = `${input.provider}:${input.sessionId}` const existing = itemsByKey.get(key) if (existing) { - existing.hasTab = true existing.timestamp = Math.max(existing.timestamp, input.timestamp ?? 0) const fallbackTitle = input.title?.trim() if (!existing.hasTitle && fallbackTitle) { @@ -168,6 +285,17 @@ export function buildSessionItems( if (!existing.firstUserMessage && input.metadata?.firstUserMessage) { existing.firstUserMessage = input.metadata.firstUserMessage } + existing.hasTab = existing.hasTab || (input.hasTab ?? true) + existing.isRestorable = existing.isRestorable || input.isRestorable + existing.codexDurability = existing.codexDurability + ?? input.codexDurability + ?? runningSessionMap.get(key)?.codexDurability + existing.codexDurabilityState = existing.codexDurabilityState + ?? input.codexDurabilityState + ?? runningSessionMap.get(key)?.codexDurabilityState + existing.codexDurabilityReason = existing.codexDurabilityReason + ?? input.codexDurabilityReason + ?? runningSessionMap.get(key)?.codexDurabilityReason if (existing.isSubagent === undefined && input.metadata?.isSubagent !== undefined) { existing.isSubagent = input.metadata.isSubagent } @@ -181,6 +309,7 @@ export function buildSessionItems( const runningTerminal = runningSessionMap.get(key) const runningTerminalId = runningTerminal?.terminalId const runningTerminalIds = runningTerminal?.allTerminalIds + const hasTab = input.hasTab ?? true const item: SidebarSessionItem = { id: `session-${input.provider}-${input.sessionId}`, sessionId: input.sessionId, @@ -192,7 +321,7 @@ export function buildSessionItems( projectPath: input.cwd, timestamp: input.timestamp ?? 0, cwd: input.cwd, - hasTab: true, + hasTab, ratchetedActivity: sessionActivity[key], isRunning: !!runningTerminalId, runningTerminalId, @@ -201,6 +330,10 @@ export function buildSessionItems( isNonInteractive: input.metadata?.isNonInteractive, firstUserMessage: input.metadata?.firstUserMessage, isFallback: true, + isRestorable: input.isRestorable ?? runningTerminal?.isRestorable, + codexDurability: input.codexDurability ?? runningTerminal?.codexDurability, + codexDurabilityState: input.codexDurabilityState ?? runningTerminal?.codexDurabilityState, + codexDurabilityReason: input.codexDurabilityReason ?? runningTerminal?.codexDurabilityReason, } items.push(item) itemsByKey.set(key, item) @@ -217,7 +350,11 @@ export function buildSessionItems( } const paneTitle = paneTitles?.[tab.id]?.[node.id] - const fallbackTimestamp = tab.lastInputAt ?? tab.createdAt ?? 0 + const fallbackTimestamp = deriveTabRecencyAt({ + tab, + layout: panes.layouts?.[tab.id], + paneLastInputAt, + }) if (node.content.kind === 'agent-chat') { const sessionRef = node.content.sessionRef @@ -235,10 +372,46 @@ export function buildSessionItems( return } + if (node.content.kind === 'fresh-agent') { + const sessionId = node.content.resumeSessionId + const runtimeProvider = resolveFreshAgentType(node.content.sessionType)?.runtimeProvider ?? node.content.provider + if (!sessionId) return + const metadata = getSessionMetadata(tab, runtimeProvider, sessionId) + pushFallbackItem({ + provider: runtimeProvider, + sessionId, + sessionType: node.content.sessionType || runtimeProvider, + title: paneTitle || tab.title, + cwd: node.content.initialCwd, + timestamp: fallbackTimestamp, + metadata, + }) + return + } + if (node.content.kind !== 'terminal') return if (node.content.mode === 'shell') return const sessionRef = node.content.sessionRef - if (!sessionRef) return + if (!sessionRef) { + const codexDurability = node.content.mode === 'codex' + ? node.content.codexDurability + : undefined + const codexSessionId = getCodexDurabilitySessionId(codexDurability) + if (!codexSessionId) return + pushFallbackItem({ + provider: 'codex', + sessionId: codexSessionId, + sessionType: 'codex', + title: paneTitle || tab.title, + cwd: node.content.initialCwd, + timestamp: fallbackTimestamp, + isRestorable: isCodexDurabilityRestorable(codexDurability), + codexDurability, + codexDurabilityState: codexDurability?.state, + codexDurabilityReason: getCodexDurabilityReason(codexDurability), + }) + return + } const metadata = getSessionMetadata(tab, sessionRef.provider, sessionRef.sessionId) pushFallbackItem({ @@ -270,11 +443,70 @@ export function buildSessionItems( sessionType: metadata?.sessionType || provider, title: tab.title, cwd: undefined, - timestamp: tab.lastInputAt ?? tab.createdAt ?? 0, + timestamp: deriveTabRecencyAt({ + tab, + layout: undefined, + paneLastInputAt, + }), metadata, }) } + for (const terminal of terminals || []) { + if (terminal.status !== 'running') continue + if (terminal.sessionRef) continue + if (!terminal.mode || terminal.mode === 'shell' || !isNonShellMode(terminal.mode)) continue + + const provider = terminal.mode as CodingCliProviderName + const codexDurability = provider === 'codex' ? terminal.codexDurability : undefined + const codexSessionId = getCodexDurabilitySessionId(codexDurability) + if (provider === 'codex' && codexSessionId) { + pushFallbackItem({ + provider: 'codex', + sessionId: codexSessionId, + sessionType: 'codex', + title: terminal.title, + cwd: terminal.cwd, + timestamp: terminal.lastActivityAt ?? terminal.createdAt, + hasTab: false, + isRestorable: isCodexDurabilityRestorable(codexDurability), + codexDurability, + codexDurabilityState: codexDurability?.state, + codexDurabilityReason: getCodexDurabilityReason(codexDurability), + }) + continue + } + + const sessionId = liveTerminalSessionId(terminal.terminalId) + const key = `${provider}:${sessionId}` + if (itemsByKey.has(key)) continue + + const paneInfo = terminalPaneTitles.get(terminal.terminalId) + const fallbackTitle = paneInfo?.title?.trim() || terminal.title?.trim() || getProviderLabel(provider) + const item: SidebarSessionItem = { + id: `session-${provider}-${sessionId}`, + sessionId, + provider, + sessionType: provider, + title: fallbackTitle, + hasTitle: fallbackTitle.length > 0, + subtitle: terminal.cwd ? getProjectName(terminal.cwd) : undefined, + projectPath: terminal.cwd, + timestamp: terminal.lastActivityAt ?? terminal.createdAt, + cwd: terminal.cwd, + hasTab: paneInfo?.hasTab ?? false, + ratchetedActivity: sessionActivity[key], + isRunning: true, + runningTerminalId: terminal.terminalId, + runningTerminalIds: [terminal.terminalId], + isFallback: true, + liveTerminalOnly: true, + isRestorable: false, + } + items.push(item) + itemsByKey.set(key, item) + } + return items } @@ -446,6 +678,7 @@ export const makeSelectSortedSessionItems = () => selectProjects, selectTabs, selectPanes, + selectPaneLastInputAt, selectSessionActivityForSort, selectSortMode, selectWorktreeGrouping, @@ -464,6 +697,7 @@ export const makeSelectSortedSessionItems = () => projects, tabs, panes, + paneLastInputAt, sessionActivity, sortMode, worktreeGrouping, @@ -478,7 +712,7 @@ export const makeSelectSortedSessionItems = () => terminals, filter ) => { - const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity, worktreeGrouping) + const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity, worktreeGrouping, paneLastInputAt) const visible = filterSessionItemsByVisibility(items, { showSubagents, ignoreCodexSubagents, diff --git a/src/store/selectors/tabsRegistrySelectors.ts b/src/store/selectors/tabsRegistrySelectors.ts index 59e53f2b2..285dfadeb 100644 --- a/src/store/selectors/tabsRegistrySelectors.ts +++ b/src/store/selectors/tabsRegistrySelectors.ts @@ -3,6 +3,9 @@ import type { RootState } from '@/store/store' import type { RegistryTabRecord } from '@/store/tabRegistryTypes' import { buildOpenTabRegistryRecord } from '@/lib/tab-registry-snapshot' import { UNKNOWN_SERVER_INSTANCE_ID } from '@/store/tabRegistryConstants' +import { deriveTabRecencyAt } from '@/lib/tab-recency' + +const EMPTY_PANE_LAST_INPUT_AT: Record<string, number | undefined> = {} function sortUpdatedDesc(a: RegistryTabRecord, b: RegistryTabRecord): number { return b.updatedAt - a.updatedAt @@ -28,21 +31,30 @@ function dedupeByTabKey(records: RegistryTabRecord[]): RegistryTabRecord[] { const selectTabs = (state: RootState) => state.tabs.tabs const selectLayouts = (state: RootState) => state.panes.layouts const selectPaneTitles = (state: RootState) => state.panes.paneTitles +const selectPaneLastInputAt = (state: RootState) => state.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT const selectDeviceId = (state: RootState) => state.tabRegistry.deviceId const selectDeviceLabel = (state: RootState) => state.tabRegistry.deviceLabel const selectServerInstanceId = (state: RootState) => state.connection.serverInstanceId || UNKNOWN_SERVER_INSTANCE_ID +const selectSameDeviceOpen = (state: RootState) => state.tabRegistry.sameDeviceOpen const selectRemoteOpen = (state: RootState) => state.tabRegistry.remoteOpen const selectClosed = (state: RootState) => state.tabRegistry.closed const selectLocalClosed = (state: RootState) => state.tabRegistry.localClosed +const selectClosedRetentionDays = (state: RootState) => Math.min(30, Math.max(1, Math.floor( + state.tabRegistry.closedTabRetentionDays ?? state.tabRegistry.searchRangeDays ?? 30, +))) export const selectLiveLocalTabRecords = createSelector( - [selectTabs, selectLayouts, selectPaneTitles, selectDeviceId, selectDeviceLabel, selectServerInstanceId], - (tabs, layouts, paneTitles, deviceId, deviceLabel, serverInstanceId): RegistryTabRecord[] => { + [selectTabs, selectLayouts, selectPaneTitles, selectPaneLastInputAt, selectDeviceId, selectDeviceLabel, selectServerInstanceId], + (tabs, layouts, paneTitles, paneLastInputAt, deviceId, deviceLabel, serverInstanceId): RegistryTabRecord[] => { const records: RegistryTabRecord[] = [] for (const tab of tabs) { const layout = layouts[tab.id] if (!layout) continue - const updatedAt = tab.lastInputAt || tab.createdAt || 0 + const updatedAt = deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt, + }) records.push(buildOpenTabRegistryRecord({ tab, layout, @@ -59,20 +71,22 @@ export const selectLiveLocalTabRecords = createSelector( ) export const selectMergedClosedRecords = createSelector( - [selectClosed, selectLocalClosed], - (closed, localClosed): RegistryTabRecord[] => { + [selectClosed, selectLocalClosed, selectClosedRetentionDays], + (closed, localClosed, closedRetentionDays): RegistryTabRecord[] => { + const closedCutoff = Date.now() - closedRetentionDays * 24 * 60 * 60 * 1000 const merged = dedupeByTabKey([ ...(closed || []), - ...Object.values(localClosed || {}), + ...Object.values(localClosed || {}).filter((record) => (record.closedAt ?? record.updatedAt) >= closedCutoff), ]) return merged.sort(sortClosedDesc) }, ) export const selectTabsRegistryGroups = createSelector( - [selectLiveLocalTabRecords, selectRemoteOpen, selectMergedClosedRecords], - (localOpen, remoteOpen, closed) => ({ + [selectLiveLocalTabRecords, selectSameDeviceOpen, selectRemoteOpen, selectMergedClosedRecords], + (localOpen, sameDeviceOpen, remoteOpen, closed) => ({ localOpen, + sameDeviceOpen: [...(sameDeviceOpen || [])].sort(sortUpdatedDesc), remoteOpen: [...(remoteOpen || [])].sort(sortUpdatedDesc), closed, }), diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index 1284bf417..ac6bbe9c3 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import type { ProjectGroup } from './types' +import type { TerminalMetaRecord } from './terminalMetaSlice' export type SessionWindowLoadingKind = 'initial' | 'search' | 'background' | 'pagination' @@ -181,6 +182,49 @@ function syncAllWindowsFromTopLevel(state: SessionsState) { } } +function patchProjectRunningState( + projects: ProjectGroup[], + payload: { + upsert: TerminalMetaRecord[] + remove: string[] + }, +) { + const clearedTerminalIds = new Set(payload.remove) + for (const record of payload.upsert) { + if (!record.provider || !record.sessionId) { + clearedTerminalIds.add(record.terminalId) + } + } + + const runningBySessionKey = new Map<string, string>() + for (const record of payload.upsert) { + if (!record.provider || !record.sessionId) continue + runningBySessionKey.set(`${record.provider}:${record.sessionId}`, record.terminalId) + } + + for (const project of projects) { + for (const session of project.sessions) { + const sessionRecord = session as typeof session & { + isRunning?: boolean + runningTerminalId?: string + } + if ( + sessionRecord.runningTerminalId + && clearedTerminalIds.has(sessionRecord.runningTerminalId) + ) { + sessionRecord.isRunning = false + sessionRecord.runningTerminalId = undefined + } + + const runningTerminalId = runningBySessionKey.get(`${session.provider || 'claude'}:${session.sessionId}`) + if (runningTerminalId) { + sessionRecord.isRunning = true + sessionRecord.runningTerminalId = runningTerminalId + } + } + } +} + export const sessionsSlice = createSlice({ name: 'sessions', initialState, @@ -350,6 +394,20 @@ export const sessionsSlice = createSlice({ state.expandedProjects = new Set(Array.from(state.expandedProjects).filter((k) => valid.has(k))) syncAllWindowsFromTopLevel(state) }, + patchSessionRunningStateFromTerminalMeta: ( + state, + action: PayloadAction<{ + upsert: TerminalMetaRecord[] + remove: string[] + }>, + ) => { + patchProjectRunningState(state.projects, action.payload) + if (!state.windows) return + for (const window of Object.values(state.windows)) { + if (!window) continue + patchProjectRunningState(window.projects, action.payload) + } + }, clearPaginationMeta: (state) => { state.totalSessions = undefined state.oldestLoadedTimestamp = undefined @@ -449,6 +507,7 @@ export const { clearProjects, mergeProjects, applySessionsPatch, + patchSessionRunningStateFromTerminalMeta, clearPaginationMeta, setPaginationMeta, appendSessionsPage, diff --git a/src/store/sessionsThunks.ts b/src/store/sessionsThunks.ts index cff38741b..1a5af354b 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -82,6 +82,9 @@ function searchResultsToProjects(results: Awaited<ReturnType<typeof searchSessio firstUserMessage: result.firstUserMessage, isSubagent: result.isSubagent, isNonInteractive: result.isNonInteractive, + isRunning: result.isRunning, + runningTerminalId: result.runningTerminalId, + liveTerminalOnly: result.liveTerminalOnly, }) grouped.set(result.projectPath, existing) diff --git a/src/store/settingsThunks.ts b/src/store/settingsThunks.ts index d13423d97..3aa91de7e 100644 --- a/src/store/settingsThunks.ts +++ b/src/store/settingsThunks.ts @@ -65,10 +65,47 @@ function normalizeAgentChatProviderPatchForApi( return normalizedProviderPatch } +function normalizeAgentProviderDefaultsPatchForApiSection(section: unknown): unknown { + if (!isRecord(section) || !isRecord(section.providers)) return section + return { + ...section, + providers: Object.fromEntries( + Object.entries(section.providers).map(([providerName, providerPatch]) => [ + providerName, + isRecord(providerPatch) ? normalizeAgentChatProviderPatchForApi(providerPatch) : providerPatch, + ]), + ), + } +} + +function removeUndefinedProperties(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(removeUndefinedProperties) + } + if (!isRecord(value)) { + return value + } + + return Object.fromEntries( + Object.entries(value) + .filter(([, child]) => child !== undefined) + .map(([key, child]) => [key, removeUndefinedProperties(child)]), + ) +} + export function normalizeServerSettingsPatchForApi(patch: ServerSettingsPatch): ServerSettingsPatch | Record<string, unknown> { + const patchRecord = isRecord(patch) ? patch : {} + const hadFreshAgent = Object.prototype.hasOwnProperty.call(patchRecord, 'freshAgent') + const hadAgentChat = Object.prototype.hasOwnProperty.call(patchRecord, 'agentChat') const normalizedPatch = isRecord(patch) ? { ...stripLocalSettings(patch) } : {} + if (!hadFreshAgent) { + delete normalizedPatch.freshAgent + } + if (!hadAgentChat) { + delete normalizedPatch.agentChat + } if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'defaultCwd') && normalizedPatch.defaultCwd == null) { normalizedPatch.defaultCwd = '' @@ -85,18 +122,14 @@ export function normalizeServerSettingsPatchForApi(patch: ServerSettingsPatch): } } - if (isRecord(normalizedPatch.agentChat) && isRecord(normalizedPatch.agentChat.providers)) { - normalizedPatch.agentChat = { - ...normalizedPatch.agentChat, - providers: Object.fromEntries( - Object.entries(normalizedPatch.agentChat.providers).map(([providerName, providerPatch]) => ( - [providerName, isRecord(providerPatch) ? normalizeAgentChatProviderPatchForApi(providerPatch) : providerPatch] - )), - ), - } + if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'freshAgent')) { + normalizedPatch.freshAgent = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.freshAgent) as any + } + if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'agentChat')) { + normalizedPatch.agentChat = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.agentChat) as any } - return normalizedPatch + return removeUndefinedProperties(normalizedPatch) as ServerSettingsPatch | Record<string, unknown> } type SaveServerSettingsGetState = () => { settings: Pick<SettingsState, 'serverSettings'> } diff --git a/src/store/storage-keys.ts b/src/store/storage-keys.ts index e84011125..73f2578f7 100644 --- a/src/store/storage-keys.ts +++ b/src/store/storage-keys.ts @@ -5,12 +5,15 @@ export const STORAGE_KEYS = { sessionActivity: 'freshell.sessionActivity.v2', terminalCursor: 'freshell.terminal-cursors.v1', browserPreferences: 'freshell.browser-preferences.v1', + tabRecency: 'freshell.tab-recency.v1', deviceId: 'freshell.device-id.v2', deviceLabel: 'freshell.device-label.v2', deviceLabelCustom: 'freshell.device-label-custom.v2', deviceFingerprint: 'freshell.device-fingerprint.v2', deviceAliases: 'freshell.device-aliases.v2', deviceDismissed: 'freshell.device-dismissed.v1', + tabRegistryClientInstanceId: 'freshell.tabs.client-instance-id.v1', + tabRegistrySnapshotRevision: 'freshell.tabs.snapshot-revision.v1', inputHistory: 'freshell.input-history.v1', } as const @@ -20,9 +23,12 @@ export const PANES_STORAGE_KEY = STORAGE_KEYS.panes export const SESSION_ACTIVITY_STORAGE_KEY = STORAGE_KEYS.sessionActivity export const TERMINAL_CURSOR_STORAGE_KEY = STORAGE_KEYS.terminalCursor export const BROWSER_PREFERENCES_STORAGE_KEY = STORAGE_KEYS.browserPreferences +export const TAB_RECENCY_STORAGE_KEY = STORAGE_KEYS.tabRecency export const DEVICE_ID_STORAGE_KEY = STORAGE_KEYS.deviceId export const DEVICE_LABEL_STORAGE_KEY = STORAGE_KEYS.deviceLabel export const DEVICE_LABEL_CUSTOM_STORAGE_KEY = STORAGE_KEYS.deviceLabelCustom export const DEVICE_FINGERPRINT_STORAGE_KEY = STORAGE_KEYS.deviceFingerprint export const DEVICE_ALIASES_STORAGE_KEY = STORAGE_KEYS.deviceAliases export const DEVICE_DISMISSED_STORAGE_KEY = STORAGE_KEYS.deviceDismissed +export const TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY = STORAGE_KEYS.tabRegistryClientInstanceId +export const TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY = STORAGE_KEYS.tabRegistrySnapshotRevision diff --git a/src/store/storage-migration.ts b/src/store/storage-migration.ts index d2b6da6e0..20a31c425 100644 --- a/src/store/storage-migration.ts +++ b/src/store/storage-migration.ts @@ -22,10 +22,12 @@ import { migrateLegacyTerminalDurableState, sanitizeSessionRef, } from '@shared/session-contract' +import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' +import { migrateLegacyFreshAgentContent } from '@shared/fresh-agent' const log = createLogger('StorageMigration') -const STORAGE_VERSION = 4 +const STORAGE_VERSION = 5 const STORAGE_VERSION_KEY = 'freshell_version' const AUTH_STORAGE_KEY = 'freshell.auth-token' const LEGACY_BROWSER_PREFERENCE_KEYS = [ @@ -57,10 +59,12 @@ function normalizeLayoutTab(tab: Record<string, unknown>): Record<string, unknow sessionRef: tab.sessionRef, resumeSessionId: typeof tab.resumeSessionId === 'string' ? tab.resumeSessionId : undefined, }) + const codexDurability = sanitizeCodexDurabilityRef(tab.codexDurability) const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, ...rest } = tab return { ...rest, ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), } } @@ -105,7 +109,7 @@ function normalizeLayoutNode(node: unknown): unknown { const candidate = node as Record<string, unknown> if (candidate.type === 'leaf' && candidate.content && typeof candidate.content === 'object') { - const content = candidate.content as Record<string, unknown> + const content = migrateLegacyFreshAgentContent(candidate.content as Record<string, unknown>) as Record<string, unknown> if (content.kind === 'terminal') { const durableState = migrateLegacyTerminalDurableState({ provider: typeof content.mode === 'string' && content.mode !== 'shell' ? content.mode : undefined, @@ -113,6 +117,7 @@ function normalizeLayoutNode(node: unknown): unknown { resumeSessionId: typeof content.resumeSessionId === 'string' ? content.resumeSessionId : undefined, }) const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content + const codexDurability = sanitizeCodexDurabilityRef(content.codexDurability) const normalizedRuntime = normalizeLegacyRecoveryFailedTerminal(rest, durableState) const isLegacyRecoveryFailed = ( rest.kind === 'terminal' @@ -127,6 +132,7 @@ function normalizeLayoutNode(node: unknown): unknown { content: { ...normalizedRuntime, ...(normalizedSessionRef ? { sessionRef: normalizedSessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), ...(!isLegacyRecoveryFailed && durableState.restoreError ? { restoreError: durableState.restoreError } : {}), }, } @@ -150,6 +156,26 @@ function normalizeLayoutNode(node: unknown): unknown { } } + if (content.kind === 'fresh-agent') { + const durableState = content.provider === 'claude' + ? migrateLegacyAgentChatDurableState({ + sessionRef: content.sessionRef, + cliSessionId: typeof content.cliSessionId === 'string' ? content.cliSessionId : undefined, + timelineSessionId: typeof content.timelineSessionId === 'string' ? content.timelineSessionId : undefined, + resumeSessionId: typeof content.resumeSessionId === 'string' ? content.resumeSessionId : undefined, + }) + : { sessionRef: sanitizeSessionRef(content.sessionRef) } + const { sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content + return { + ...candidate, + content: { + ...rest, + ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), + ...('restoreError' in durableState && durableState.restoreError ? { restoreError: durableState.restoreError } : {}), + }, + } + } + const sanitizedSessionRef = sanitizeSessionRef(content.sessionRef) if (!sanitizedSessionRef) return node diff --git a/src/store/store.ts b/src/store/store.ts index 5b3319f0c..081996700 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,12 +9,14 @@ import panesReducer from './panesSlice' import sessionActivityReducer from './sessionActivitySlice' import terminalActivityReducer from './terminalActivitySlice' import terminalDirectoryReducer from './terminalDirectorySlice' +import tabRecencyReducer from './tabRecencySlice' import turnCompletionReducer from './turnCompletionSlice' import terminalMetaReducer from './terminalMetaSlice' import codexActivityReducer from './codexActivitySlice' import opencodeActivityReducer from './opencodeActivitySlice' import agentChatReducer from './agentChatSlice' +import freshAgentReducer from './freshAgentSlice' import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' import { networkReducer } from './networkSlice' import tabRegistryReducer from './tabRegistrySlice' @@ -27,6 +29,10 @@ import { createLogger } from '@/lib/client-logger' import { layoutMirrorMiddleware } from './layoutMirrorMiddleware' import { serverSettingsSaveStateMiddleware } from './settingsThunks' import { tabFallbackIdentityMiddleware } from './tabFallbackIdentityMiddleware' +import { + pruneTabRecencyToCurrentLayout, + tabRecencyPruneMiddleware, +} from './tabRecencyPruneMiddleware' enableMapSet() @@ -43,12 +49,14 @@ export const store = configureStore({ sessionActivity: sessionActivityReducer, terminalActivity: terminalActivityReducer, terminalDirectory: terminalDirectoryReducer, + tabRecency: tabRecencyReducer, turnCompletion: turnCompletionReducer, terminalMeta: terminalMetaReducer, codexActivity: codexActivityReducer, opencodeActivity: opencodeActivityReducer, agentChat: agentChatReducer, + freshAgent: freshAgentReducer, paneRuntimeActivity: paneRuntimeActivityReducer, network: networkReducer, tabRegistry: tabRegistryReducer, @@ -62,6 +70,7 @@ export const store = configureStore({ }).concat( perfMiddleware, tabFallbackIdentityMiddleware, + tabRecencyPruneMiddleware, persistMiddleware, serverSettingsSaveStateMiddleware, browserPreferencesPersistenceMiddleware, @@ -70,6 +79,8 @@ export const store = configureStore({ ), }) +pruneTabRecencyToCurrentLayout(store) + // Note: Tabs and Panes are now loaded from localStorage directly in their slice // initial states (see tabsSlice.ts and panesSlice.ts). This ensures the state // is available BEFORE the store is created, preventing any race conditions. diff --git a/src/store/tabRecencyPruneMiddleware.ts b/src/store/tabRecencyPruneMiddleware.ts new file mode 100644 index 000000000..2e245a7af --- /dev/null +++ b/src/store/tabRecencyPruneMiddleware.ts @@ -0,0 +1,84 @@ +import type { Middleware } from '@reduxjs/toolkit' +import { collectTerminalPaneIds } from '@/lib/tab-recency' +import { prunePaneTabActivityToLiveTerminalPanes } from './tabRecencySlice' + +type RecencyPruneState = { + tabs?: { tabs?: Array<{ id: string }> } + panes?: { layouts?: Record<string, unknown> } +} + +function collectLiveTabIds(state: RecencyPruneState): string[] { + return (state.tabs?.tabs ?? []).map((tab) => tab.id) +} + +export function collectLiveTerminalPaneIds(state: RecencyPruneState): string[] { + const liveTabIds = new Set(collectLiveTabIds(state)) + return Object.entries(state.panes?.layouts ?? {}) + .filter(([tabId]) => liveTabIds.has(tabId)) + .flatMap(([, layout]) => collectTerminalPaneIds(layout as any)) +} + +function sameStringSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + const bSet = new Set(b) + return a.every((value) => bSet.has(value)) +} + +export const tabRecencyPruneMiddleware: Middleware<{}, RecencyPruneState> = (store) => { + let pruneQueued = false + + const pruneToCurrentState = () => { + pruneQueued = false + pruneTabRecencyToCurrentLayout(store) + } + + const queuePruneToCurrentState = () => { + if (pruneQueued) return + pruneQueued = true + const enqueue = typeof queueMicrotask === 'function' + ? queueMicrotask + : (fn: () => void) => setTimeout(fn, 0) + enqueue(pruneToCurrentState) + } + + return (next) => (action) => { + const a = action as any + if (typeof a?.type !== 'string') return next(action) + if (!a.type.startsWith('tabs/') && !a.type.startsWith('panes/')) { + return next(action) + } + + const previousState = store.getState() + const previousLiveTabIds = collectLiveTabIds(previousState) + const result = next(action) + const state = store.getState() + + const liveTabIdsChanged = !sameStringSet(previousLiveTabIds, collectLiveTabIds(state)) + const paneLayoutsChanged = state.panes?.layouts !== previousState.panes?.layouts + if (!liveTabIdsChanged && !paneLayoutsChanged) return result + + const previousLivePaneIds = collectLiveTerminalPaneIds(previousState) + const livePaneIds = collectLiveTerminalPaneIds(state) + if (!sameStringSet(previousLivePaneIds, livePaneIds)) { + store.dispatch(prunePaneTabActivityToLiveTerminalPanes({ + paneIds: livePaneIds, + })) + return result + } + + if (liveTabIdsChanged) { + queuePruneToCurrentState() + } + + return result + } +} + +export function pruneTabRecencyToCurrentLayout(store: { + getState: () => RecencyPruneState + dispatch: (action: any) => any +}): void { + store.dispatch(prunePaneTabActivityToLiveTerminalPanes({ + paneIds: collectLiveTerminalPaneIds(store.getState()), + })) +} diff --git a/src/store/tabRecencySlice.ts b/src/store/tabRecencySlice.ts new file mode 100644 index 000000000..b9256b17c --- /dev/null +++ b/src/store/tabRecencySlice.ts @@ -0,0 +1,133 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { bucketTabRecencyAt, collectTerminalPaneIds } from '@/lib/tab-recency' +import type { PaneNode } from './paneTypes' +import { TAB_RECENCY_STORAGE_KEY } from './storage-keys' + +export interface TabRecencyState { + paneLastInputAt: Record<string, number> +} + +export type PersistedTabRecencyPayload = { + version: 1 + paneLastInputAt: Record<string, number> +} + +function emptyState(): TabRecencyState { + return { + paneLastInputAt: {}, + } +} + +export function loadPersistedTabRecency(raw: string | null | undefined): TabRecencyState { + if (!raw) return emptyState() + try { + const parsed = JSON.parse(raw) as Partial<PersistedTabRecencyPayload> + if (parsed.version !== 1 || !parsed.paneLastInputAt || typeof parsed.paneLastInputAt !== 'object') { + return emptyState() + } + const paneLastInputAt: Record<string, number> = {} + for (const [paneId, value] of Object.entries(parsed.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (trimmed && bucket !== undefined) paneLastInputAt[trimmed] = bucket + } + return { paneLastInputAt } + } catch { + return emptyState() + } +} + +export function loadInitialTabRecencyState(): TabRecencyState { + try { + return loadPersistedTabRecency(typeof localStorage !== 'undefined' + ? localStorage.getItem(TAB_RECENCY_STORAGE_KEY) + : null) + } catch { + return emptyState() + } +} + +export function mergeTabRecencyStatesByMax( + base: TabRecencyState, + incoming: TabRecencyState, +): TabRecencyState { + const paneLastInputAt = { ...base.paneLastInputAt } + for (const [paneId, value] of Object.entries(incoming.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (!trimmed || bucket === undefined) continue + const current = paneLastInputAt[trimmed] + if (current === undefined || bucket > current) { + paneLastInputAt[trimmed] = bucket + } + } + return { paneLastInputAt } +} + +export function serializePersistableTabRecency( + state: TabRecencyState, + layouts?: Record<string, PaneNode | undefined>, + liveTabIds?: ReadonlySet<string>, +): PersistedTabRecencyPayload { + const layoutEntries = layouts + ? Object.entries(layouts).filter(([tabId]) => !liveTabIds || liveTabIds.has(tabId)) + : [] + const liveTerminalPaneIds = layouts + ? new Set(layoutEntries.flatMap(([, layout]) => collectTerminalPaneIds(layout))) + : undefined + const paneLastInputAt: Record<string, number> = {} + + for (const [paneId, value] of Object.entries(state.paneLastInputAt)) { + if (liveTerminalPaneIds && !liveTerminalPaneIds.has(paneId)) continue + const bucket = bucketTabRecencyAt(value) + if (paneId.trim() && bucket !== undefined) paneLastInputAt[paneId] = bucket + } + + return { + version: 1, + paneLastInputAt, + } +} + +const tabRecencySlice = createSlice({ + name: 'tabRecency', + initialState: loadInitialTabRecencyState(), + reducers: { + mergeHydratedTabRecency: (state, action: PayloadAction<TabRecencyState>) => { + for (const [paneId, value] of Object.entries(action.payload.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (!trimmed || bucket === undefined) continue + const current = state.paneLastInputAt[trimmed] + if (current === undefined || bucket > current) { + state.paneLastInputAt[trimmed] = bucket + } + } + }, + recordPaneTabActivity: (state, action: PayloadAction<{ paneId: string; at: number }>) => { + const paneId = action.payload.paneId.trim() + if (!paneId) return + const bucket = bucketTabRecencyAt(action.payload.at) + if (bucket === undefined) return + const current = state.paneLastInputAt[paneId] + if (current === undefined || bucket > current) { + state.paneLastInputAt[paneId] = bucket + } + }, + prunePaneTabActivityToLiveTerminalPanes: (state, action: PayloadAction<{ paneIds: string[] }>) => { + const livePaneIds = new Set(action.payload.paneIds.map((paneId) => paneId.trim()).filter(Boolean)) + for (const paneId of Object.keys(state.paneLastInputAt)) { + if (!livePaneIds.has(paneId)) { + delete state.paneLastInputAt[paneId] + } + } + }, + }, +}) + +export const { + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, + recordPaneTabActivity, +} = tabRecencySlice.actions +export default tabRecencySlice.reducer diff --git a/src/store/tabRegistrySlice.ts b/src/store/tabRegistrySlice.ts index 0d2e2ac26..5ca5f1dee 100644 --- a/src/store/tabRegistrySlice.ts +++ b/src/store/tabRegistrySlice.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import type { RegistryTabRecord } from './tabRegistryTypes' import type { Tab } from './types' import type { PaneNode } from './paneTypes' -import { getSearchRangeDaysPreference } from '@/lib/browser-preferences' +import { getClosedTabRetentionDaysPreference } from '@/lib/browser-preferences' import { DEVICE_ALIASES_STORAGE_KEY, DEVICE_DISMISSED_STORAGE_KEY, @@ -228,10 +228,13 @@ export interface TabRegistryState { deviceAliases: Record<string, string> dismissedDeviceIds: string[] localOpen: RegistryTabRecord[] + sameDeviceOpen: RegistryTabRecord[] remoteOpen: RegistryTabRecord[] closed: RegistryTabRecord[] + devices: Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> localClosed: Record<string, RegistryTabRecord> reopenStack: ClosedTabEntry[] + closedTabRetentionDays: number searchRangeDays: number loading: boolean syncError?: string @@ -241,7 +244,7 @@ export interface TabRegistryState { const device = loadDeviceMeta() const aliases = loadDeviceAliases(safeStorage()) const dismissedDeviceIds = loadDismissedDeviceIds(safeStorage()) -const initialSearchRangeDays = getSearchRangeDaysPreference() +const initialClosedTabRetentionDays = getClosedTabRetentionDaysPreference() const initialState: TabRegistryState = { deviceId: device.deviceId, @@ -249,11 +252,14 @@ const initialState: TabRegistryState = { deviceAliases: aliases, dismissedDeviceIds, localOpen: [], + sameDeviceOpen: [], remoteOpen: [], + devices: [], closed: [], localClosed: {}, reopenStack: [], - searchRangeDays: initialSearchRangeDays, + closedTabRetentionDays: initialClosedTabRetentionDays, + searchRangeDays: initialClosedTabRetentionDays, loading: false, } @@ -278,7 +284,14 @@ export const tabRegistrySlice = createSlice({ state.dismissedDeviceIds = action.payload }, setTabRegistrySearchRangeDays: (state, action: PayloadAction<number>) => { - state.searchRangeDays = Math.max(1, action.payload) + const closedTabRetentionDays = Math.min(30, Math.max(1, Math.floor(action.payload))) + state.closedTabRetentionDays = closedTabRetentionDays + state.searchRangeDays = closedTabRetentionDays + }, + setTabRegistryClosedTabRetentionDays: (state, action: PayloadAction<number>) => { + const closedTabRetentionDays = Math.min(30, Math.max(1, Math.floor(action.payload))) + state.closedTabRetentionDays = closedTabRetentionDays + state.searchRangeDays = closedTabRetentionDays }, setTabRegistryLoading: (state, action: PayloadAction<boolean>) => { state.loading = action.payload @@ -287,13 +300,17 @@ export const tabRegistrySlice = createSlice({ state, action: PayloadAction<{ localOpen: RegistryTabRecord[] + sameDeviceOpen?: RegistryTabRecord[] remoteOpen: RegistryTabRecord[] closed: RegistryTabRecord[] + devices?: Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> }>, ) => { state.localOpen = action.payload.localOpen || [] + state.sameDeviceOpen = action.payload.sameDeviceOpen || [] state.remoteOpen = action.payload.remoteOpen || [] state.closed = action.payload.closed || [] + state.devices = action.payload.devices || [] state.lastSnapshotAt = Date.now() state.syncError = undefined state.loading = false @@ -304,6 +321,9 @@ export const tabRegistrySlice = createSlice({ recordClosedTabSnapshot: (state, action: PayloadAction<RegistryTabRecord>) => { state.localClosed[action.payload.tabKey] = action.payload }, + clearTabRegistryLocalClosed: (state) => { + state.localClosed = {} + }, pushReopenEntry: (state, action: PayloadAction<ClosedTabEntry>) => { state.reopenStack.push(action.payload) if (state.reopenStack.length > REOPEN_STACK_MAX) { @@ -322,10 +342,12 @@ export const { setTabRegistryDeviceAliases, setTabRegistryDismissedDeviceIds, setTabRegistrySearchRangeDays, + setTabRegistryClosedTabRetentionDays, setTabRegistryLoading, setTabRegistrySnapshot, setTabRegistrySyncError, recordClosedTabSnapshot, + clearTabRegistryLocalClosed, pushReopenEntry, popReopenEntry, } = tabRegistrySlice.actions diff --git a/src/store/tabRegistrySync.ts b/src/store/tabRegistrySync.ts index 2c544eb62..b940c1f4f 100644 --- a/src/store/tabRegistrySync.ts +++ b/src/store/tabRegistrySync.ts @@ -3,32 +3,131 @@ import type { RootState } from './store' import type { WsClient } from '@/lib/ws-client' import type { RegistryTabRecord } from './tabRegistryTypes' import { + clearTabRegistryLocalClosed, setTabRegistryLoading, setTabRegistrySnapshot, setTabRegistrySyncError, } from './tabRegistrySlice' import { buildOpenTabRegistryRecord } from '@/lib/tab-registry-snapshot' import type { PaneNode } from './paneTypes' +import { + TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY, + TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY, +} from './storage-keys' +import { deriveTabRecencyAt } from '@/lib/tab-recency' export const SYNC_INTERVAL_MS = 5000 +export const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000 +export const CLIENT_LEASE_GRACE_MS = 50 type AppStore = Store<RootState> type TabRegistryWsClient = Pick<WsClient, 'state' | 'onMessage' | 'serverInstanceId'> & { sendTabsSyncPush?: WsClient['sendTabsSyncPush'] sendTabsSyncQuery?: WsClient['sendTabsSyncQuery'] + sendTabsSyncClientRetire?: WsClient['sendTabsSyncClientRetire'] onReconnect?: WsClient['onReconnect'] } -type RevisionState = Map<string, { fingerprint: string; revision: number }> +type RevisionState = Map<string, { fingerprint: string; revision: number; updatedAt: number }> +const claimedClientInstanceIds = new Set<string>() +const TAB_REGISTRY_CLIENT_LEASE_CHANNEL = 'freshell-tabs-registry-client-lease' +let inMemoryClientInstanceId = '' +let inMemorySnapshotRevision = 0 + +function randomClientInstanceId(): string { + return `client-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}` +} + +function safeSessionStorage(): Storage | null { + try { + return typeof sessionStorage !== 'undefined' ? sessionStorage : null + } catch { + return null + } +} + +export function getCurrentTabRegistryClientInstanceId(): string { + const storage = safeSessionStorage() + let clientInstanceId = '' + try { + clientInstanceId = storage?.getItem(TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY) || '' + } catch { + clientInstanceId = inMemoryClientInstanceId + } + if (!storage) { + clientInstanceId = inMemoryClientInstanceId + } + if (!clientInstanceId) { + clientInstanceId = randomClientInstanceId() + inMemoryClientInstanceId = clientInstanceId + inMemorySnapshotRevision = 0 + try { + storage?.setItem(TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY, clientInstanceId) + storage?.setItem(TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY, '0') + } catch { + // Keep the per-window module fallback stable when sessionStorage is unavailable. + } + } + inMemoryClientInstanceId = clientInstanceId + return clientInstanceId +} + +function claimTabRegistryClientInstanceId(): string { + const storage = safeSessionStorage() + let clientInstanceId = getCurrentTabRegistryClientInstanceId() + if (!clientInstanceId || claimedClientInstanceIds.has(clientInstanceId)) { + clientInstanceId = randomClientInstanceId() + inMemoryClientInstanceId = clientInstanceId + inMemorySnapshotRevision = 0 + try { + storage?.setItem(TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY, clientInstanceId) + storage?.setItem(TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY, '0') + } catch { + // Keep the per-window module fallback stable when sessionStorage is unavailable. + } + } + claimedClientInstanceIds.add(clientInstanceId) + return clientInstanceId +} + +function readSnapshotRevision(): number { + let raw: string | null | undefined + try { + raw = safeSessionStorage()?.getItem(TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY) + } catch { + raw = String(inMemorySnapshotRevision) + } + if (raw == null && inMemorySnapshotRevision > 0) raw = String(inMemorySnapshotRevision) + const parsed = raw ? Number(raw) : 0 + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0 +} + +function writeSnapshotRevision(revision: number): void { + inMemorySnapshotRevision = revision + try { + safeSessionStorage()?.setItem(TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY, String(revision)) + } catch { + // Keep the per-window module fallback stable when sessionStorage is unavailable. + } +} + +function stableStringifyForFingerprint(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map((item) => stableStringifyForFingerprint(item)).join(',')}]` + const entries = Object.entries(value as Record<string, unknown>) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringifyForFingerprint(entryValue)}`).join(',')}}` +} function paneLayoutSignature(node: PaneNode | undefined): string { if (!node) return 'none' - if (node.type === 'leaf') return `leaf:${node.id}:${node.content.kind}` + if (node.type === 'leaf') return `leaf:${node.id}:${stableStringifyForFingerprint(node.content)}` return `split:${node.id}:${node.direction}:${paneLayoutSignature(node.children[0])}|${paneLayoutSignature(node.children[1])}` } -function nextRevision(record: RegistryTabRecord, revisions: RevisionState): number { - const fingerprint = JSON.stringify({ +function recordFingerprint(record: RegistryTabRecord): string { + return stableStringifyForFingerprint({ status: record.status, tabName: record.tabName, paneCount: record.paneCount, @@ -36,26 +135,56 @@ function nextRevision(record: RegistryTabRecord, revisions: RevisionState): numb panes: record.panes, closedAt: record.closedAt, }) +} + +function nextRecordVersion(record: RegistryTabRecord, revisions: RevisionState, now: number): { revision: number; updatedAt: number } { + const fingerprint = recordFingerprint(record) const current = revisions.get(record.tabKey) if (!current) { - revisions.set(record.tabKey, { fingerprint, revision: 1 }) - return 1 + const updatedAt = record.updatedAt ?? now + revisions.set(record.tabKey, { fingerprint, revision: 1, updatedAt }) + return { revision: 1, updatedAt } } if (current.fingerprint === fingerprint) { - return current.revision + const incomingUpdatedAt = record.updatedAt ?? 0 + if (incomingUpdatedAt > current.updatedAt) { + const revision = current.revision + 1 + revisions.set(record.tabKey, { fingerprint, revision, updatedAt: incomingUpdatedAt }) + return { revision, updatedAt: incomingUpdatedAt } + } + return { revision: current.revision, updatedAt: current.updatedAt } } const revision = current.revision + 1 - revisions.set(record.tabKey, { fingerprint, revision }) - return revision + const updatedAt = Math.max(now, record.updatedAt ?? 0, current.updatedAt + 1) + revisions.set(record.tabKey, { fingerprint, revision, updatedAt }) + return { revision, updatedAt } +} + +function selectedClosedRetentionDays(state: RootState): number { + return Math.min(30, Math.max(1, Math.floor( + state.tabRegistry.closedTabRetentionDays ?? state.tabRegistry.searchRangeDays ?? 30, + ))) } function buildRecords(state: RootState, now: number, revisions: RevisionState, serverInstanceId: string): RegistryTabRecord[] { const records: RegistryTabRecord[] = [] const { deviceId, deviceLabel } = state.tabRegistry + const closedCutoff = now - selectedClosedRetentionDays(state) * 24 * 60 * 60 * 1000 + const retainedClosedRecords = Object.values(state.tabRegistry.localClosed).filter((closed) => { + if (closed.serverInstanceId !== serverInstanceId) return false + const closedAt = closed.closedAt ?? closed.updatedAt + return closedAt >= closedCutoff + }) + const retainedClosedTabKeys = new Set(retainedClosedRecords.map((closed) => closed.tabKey)) for (const tab of state.tabs.tabs) { const layout = state.panes.layouts[tab.id] if (!layout) continue + const updatedAt = deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: state.tabRecency?.paneLastInputAt ?? {}, + }) const recordBase = buildOpenTabRegistryRecord({ tab, layout, @@ -64,23 +193,29 @@ function buildRecords(state: RootState, now: number, revisions: RevisionState, s deviceId, deviceLabel, revision: 0, - updatedAt: tab.lastInputAt || tab.createdAt || now, + updatedAt, }) + if (retainedClosedTabKeys.has(recordBase.tabKey)) continue + const version = nextRecordVersion(recordBase, revisions, now) records.push({ ...recordBase, - revision: nextRevision(recordBase, revisions), + ...version, }) } - for (const closed of Object.values(state.tabRegistry.localClosed)) { + for (const closed of retainedClosedRecords) { + const closedAt = closed.closedAt ?? closed.updatedAt const recordBase: RegistryTabRecord = { ...closed, + deviceId, + deviceLabel, updatedAt: closed.updatedAt, - closedAt: closed.closedAt ?? closed.updatedAt, + closedAt, } + const version = nextRecordVersion(recordBase, revisions, now) records.push({ ...recordBase, - revision: nextRevision(recordBase, revisions), + ...version, }) } @@ -97,20 +232,37 @@ function lifecycleSignature(state: RootState): string { status: tab.status, mode: tab.mode, titleSetByUser: !!tab.titleSetByUser, + recencyAt: deriveTabRecencyAt({ + tab, + layout: state.panes.layouts[tab.id], + paneLastInputAt: state.tabRecency?.paneLastInputAt ?? {}, + }), })), panes: Object.entries(state.panes.layouts).map(([tabId, node]) => ({ tabId, sig: paneLayoutSignature(node), })), closedKeys: Object.keys(state.tabRegistry.localClosed).sort(), + closedTabRetentionDays: selectedClosedRetentionDays(state), }) } export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): () => void { + const storage = safeSessionStorage() + let hadStoredClientInstanceId = false + try { + hadStoredClientInstanceId = !!storage?.getItem(TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY) + } catch { + hadStoredClientInstanceId = !!inMemoryClientInstanceId + } + let clientInstanceId = claimTabRegistryClientInstanceId() + const leaseId = randomClientInstanceId() const sendTabsSyncPush = ws.sendTabsSyncPush?.bind(ws) - ?? ((_payload: { deviceId: string; deviceLabel: string; records: RegistryTabRecord[] }) => {}) + ?? ((_payload: { deviceId: string; deviceLabel: string; clientInstanceId: string; snapshotRevision: number; records: RegistryTabRecord[] }) => {}) const sendTabsSyncQuery = ws.sendTabsSyncQuery?.bind(ws) - ?? ((_payload: { requestId: string; deviceId: string; rangeDays?: number }) => {}) + ?? ((_payload: { requestId: string; deviceId: string; clientInstanceId: string; closedTabRetentionDays: number }) => {}) + const sendTabsSyncClientRetire = ws.sendTabsSyncClientRetire?.bind(ws) + ?? ((_payload: { deviceId: string; clientInstanceId: string; snapshotRevision: number }) => {}) const onReconnect = ws.onReconnect?.bind(ws) ?? ((_handler: () => void) => () => {}) @@ -118,40 +270,158 @@ export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): const pendingRequests = new Set<string>() let lastPushFingerprint = '' let lastLifecycleFingerprint = lifecycleSignature(store.getState()) + let lastClosedRetentionDays = selectedClosedRetentionDays(store.getState()) + let snapshotRevision = readSnapshotRevision() + let lastServerInstanceId = ws.serverInstanceId || store.getState().connection.serverInstanceId + let retired = false + let leaseChannel: BroadcastChannel | null = null + const shouldVerifyClientLease = hadStoredClientInstanceId && typeof BroadcastChannel !== 'undefined' + let leaseSettled = !shouldVerifyClientLease + let leaseSettleTimer: ReturnType<typeof globalThis.setTimeout> | undefined + let queuedQuery = false + let queuedPush = false + let queuedForcedPush = false + let latestQueryRequestId = '' - const querySnapshot = (rangeDays?: number) => { + const querySnapshot = (closedTabRetentionDays?: number) => { + if (!leaseSettled) { + queuedQuery = true + return + } if (ws.state !== 'ready') return - const searchRangeDays = store.getState().tabRegistry.searchRangeDays - const effectiveRangeDays = rangeDays ?? searchRangeDays + const state = store.getState() + const retentionDays = Math.min(30, Math.max(1, closedTabRetentionDays ?? selectedClosedRetentionDays(state))) const requestId = `tabs-sync-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` pendingRequests.add(requestId) + latestQueryRequestId = requestId store.dispatch(setTabRegistryLoading(true)) sendTabsSyncQuery({ requestId, - deviceId: store.getState().tabRegistry.deviceId, - ...(effectiveRangeDays > 30 ? { rangeDays: effectiveRangeDays } : {}), + deviceId: state.tabRegistry.deviceId, + clientInstanceId, + closedTabRetentionDays: retentionDays, }) } const pushNow = (force = false) => { + if (!leaseSettled) { + queuedPush = true + queuedForcedPush ||= force + return + } if (ws.state !== 'ready') return const state = store.getState() - const serverInstanceId = state.connection.serverInstanceId || ws.serverInstanceId - // Do not publish snapshot records until the server identity is known. - // Without this, tabs can be attributed to a synthetic/unstable server key. + const serverInstanceId = ws.serverInstanceId || state.connection.serverInstanceId if (!serverInstanceId) return - const records = buildRecords(state, Date.now(), revisions, serverInstanceId) + if (lastServerInstanceId && serverInstanceId !== lastServerInstanceId && Object.keys(state.tabRegistry.localClosed).length > 0) { + store.dispatch(clearTabRegistryLocalClosed()) + } + lastServerInstanceId = serverInstanceId + const records = buildRecords(store.getState(), Date.now(), revisions, serverInstanceId) const fingerprint = JSON.stringify(records) if (!force && fingerprint === lastPushFingerprint) return lastPushFingerprint = fingerprint + snapshotRevision += 1 + writeSnapshotRevision(snapshotRevision) + const nextState = store.getState() sendTabsSyncPush({ - deviceId: state.tabRegistry.deviceId, - deviceLabel: state.tabRegistry.deviceLabel, + deviceId: nextState.tabRegistry.deviceId, + deviceLabel: nextState.tabRegistry.deviceLabel, + clientInstanceId, + snapshotRevision, records, }) store.dispatch(setTabRegistrySyncError(undefined)) } + const announceLease = () => { + leaseChannel?.postMessage({ + type: 'tabs-registry-client-claim', + clientInstanceId, + leaseId, + }) + } + + const settleClientLease = () => { + leaseSettled = true + if (leaseSettleTimer) { + globalThis.clearTimeout(leaseSettleTimer) + leaseSettleTimer = undefined + } + const shouldQuery = queuedQuery + const shouldPush = queuedPush + const shouldForcePush = queuedForcedPush + queuedQuery = false + queuedPush = false + queuedForcedPush = false + if (shouldQuery) querySnapshot() + if (shouldPush) pushNow(shouldForcePush) + } + + const beginClientLeaseCheck = () => { + leaseSettled = false + if (leaseSettleTimer) globalThis.clearTimeout(leaseSettleTimer) + announceLease() + leaseSettleTimer = globalThis.setTimeout(settleClientLease, CLIENT_LEASE_GRACE_MS) + } + + const rotateClientInstanceIdAfterCollision = () => { + const previousClientInstanceId = clientInstanceId + claimedClientInstanceIds.delete(previousClientInstanceId) + clientInstanceId = randomClientInstanceId() + inMemoryClientInstanceId = clientInstanceId + claimedClientInstanceIds.add(clientInstanceId) + try { + safeSessionStorage()?.setItem(TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY, clientInstanceId) + } catch { + // Keep the per-window module fallback stable when sessionStorage is unavailable. + } + snapshotRevision = 0 + writeSnapshotRevision(snapshotRevision) + lastPushFingerprint = '' + pendingRequests.clear() + latestQueryRequestId = '' + retired = false + beginClientLeaseCheck() + querySnapshot() + pushNow(true) + } + + if (typeof BroadcastChannel !== 'undefined') { + leaseChannel = new BroadcastChannel(TAB_REGISTRY_CLIENT_LEASE_CHANNEL) + leaseChannel.onmessage = (event: MessageEvent) => { + const data = event.data as { type?: string; clientInstanceId?: string; leaseId?: string; claimantLeaseId?: string } + if ( + data?.type === 'tabs-registry-client-claim' + && data.clientInstanceId === clientInstanceId + && data.leaseId + && data.leaseId !== leaseId + ) { + leaseChannel?.postMessage({ + type: 'tabs-registry-client-active', + clientInstanceId, + leaseId, + claimantLeaseId: data.leaseId, + }) + return + } + if ( + data?.type === 'tabs-registry-client-active' + && data.clientInstanceId === clientInstanceId + && data.leaseId + && data.leaseId !== leaseId + && data.claimantLeaseId === leaseId + ) { + rotateClientInstanceIdAfterCollision() + } + } + if (shouldVerifyClientLease) { + beginClientLeaseCheck() + } else { + announceLease() + } + } + const unsubscribeMessage = ws.onMessage((msg) => { if (msg?.type === 'ready') { querySnapshot() @@ -161,18 +431,22 @@ export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): if (msg?.type === 'tabs.sync.snapshot') { const requestId = typeof msg.requestId === 'string' ? msg.requestId : '' - if (requestId && pendingRequests.has(requestId)) { - pendingRequests.delete(requestId) - } + if (!requestId || !pendingRequests.has(requestId) || requestId !== latestQueryRequestId) return + pendingRequests.delete(requestId) + pendingRequests.clear() const data = (msg.data || {}) as { localOpen?: RegistryTabRecord[] + sameDeviceOpen?: RegistryTabRecord[] remoteOpen?: RegistryTabRecord[] closed?: RegistryTabRecord[] + devices?: Array<{ deviceId: string; deviceLabel: string; lastSeenAt: number }> } store.dispatch(setTabRegistrySnapshot({ localOpen: data.localOpen || [], + sameDeviceOpen: data.sameDeviceOpen || [], remoteOpen: data.remoteOpen || [], closed: data.closed || [], + devices: data.devices || [], })) return } @@ -190,16 +464,53 @@ export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): const interval = globalThis.setInterval(() => { pushNow() }, SYNC_INTERVAL_MS) + const heartbeatInterval = globalThis.setInterval(() => { + pushNow(true) + }, HEARTBEAT_INTERVAL_MS) const unsubscribeStore = store.subscribe(() => { const state = store.getState() const nextFingerprint = lifecycleSignature(state) if (nextFingerprint === lastLifecycleFingerprint) return lastLifecycleFingerprint = nextFingerprint + const nextRetentionDays = selectedClosedRetentionDays(state) + if (nextRetentionDays !== lastClosedRetentionDays) { + lastClosedRetentionDays = nextRetentionDays + querySnapshot(nextRetentionDays) + } pushNow() }) - // Kick off immediately when already connected. + const retire = () => { + if (retired) return + retired = true + const state = store.getState() + snapshotRevision += 1 + writeSnapshotRevision(snapshotRevision) + const payload = { + deviceId: state.tabRegistry.deviceId, + clientInstanceId, + snapshotRevision, + } + sendTabsSyncClientRetire({ + ...payload, + }) + const body = JSON.stringify(payload) + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + const blob = new Blob([body], { type: 'application/json' }) + navigator.sendBeacon('/api/tabs-sync/client-retire', blob) + } else if (typeof fetch === 'function') { + void fetch('/api/tabs-sync/client-retire', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}) + } + } + globalThis.addEventListener?.('pagehide', retire) + globalThis.addEventListener?.('beforeunload', retire) + querySnapshot() pushNow(true) @@ -208,5 +519,12 @@ export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): unsubscribeReconnect() unsubscribeStore() globalThis.clearInterval(interval) + globalThis.clearInterval(heartbeatInterval) + if (leaseSettleTimer) globalThis.clearTimeout(leaseSettleTimer) + globalThis.removeEventListener?.('pagehide', retire) + globalThis.removeEventListener?.('beforeunload', retire) + leaseChannel?.close() + claimedClientInstanceIds.delete(clientInstanceId) + retire() } } diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 48360b70f..58fe8c89f 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -23,6 +23,8 @@ import { createLogger } from '@/lib/client-logger' import { mergeSessionMetadataByKey, sessionMetadataKey } from '@/lib/session-metadata' import { mergeSessionMetadataForPreferredResumeId } from './persistControl' import { migrateLegacyTerminalDurableState, sanitizeSessionRef } from '@shared/session-contract' +import type { CodexDurabilityRef } from '@shared/codex-durability' +import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' import { sanitizeTabsAgainstLayouts } from '@/lib/tab-fallback-identity' @@ -69,6 +71,7 @@ function migrateTabFields(t: Tab): Tab { sessionRef: (t as any).sessionRef, resumeSessionId: t.resumeSessionId, }) + const codexDurability = sanitizeCodexDurabilityRef((t as any).codexDurability) return { ...rest, codingCliSessionId: t.codingCliSessionId || legacyClaudeSessionId, @@ -79,6 +82,7 @@ function migrateTabFields(t: Tab): Tab { mode: t.mode || 'shell', shell: t.shell || 'system', sessionRef: durableState.sessionRef, + codexDurability, resumeSessionId: undefined, lastInputAt: t.lastInputAt, } @@ -232,6 +236,7 @@ type AddTabPayload = { shell?: ShellType initialCwd?: string sessionRef?: Tab['sessionRef'] + codexDurability?: Tab['codexDurability'] serverInstanceId?: string resumeSessionId?: string sessionMetadataByKey?: Tab['sessionMetadataByKey'] @@ -254,6 +259,7 @@ export const tabsSlice = createSlice({ const codingCliProvider = payload.codingCliProvider || (legacyClaudeSessionId ? 'claude' : undefined) const sessionRef = sanitizeSessionRef(payload.sessionRef) + const codexDurability = sanitizeCodexDurabilityRef(payload.codexDurability) const tab: Tab = { id, createRequestId: payload.createRequestId || id, @@ -267,6 +273,7 @@ export const tabsSlice = createSlice({ shell: payload.shell || 'system', initialCwd: payload.initialCwd, sessionRef, + codexDurability, serverInstanceId: payload.serverInstanceId, resumeSessionId: undefined, sessionMetadataByKey: payload.sessionMetadataByKey, @@ -524,7 +531,7 @@ export const reopenClosedTab = createAsyncThunk( export const openSessionTab = createAsyncThunk( 'tabs/openSessionTab', async ( - { sessionId, title, cwd, provider, sessionType, terminalId, forceNew, firstUserMessage, isSubagent, isNonInteractive }: { + { sessionId, title, cwd, provider, sessionType, terminalId, forceNew, firstUserMessage, isSubagent, isNonInteractive, isRestorable, codexDurability }: { sessionId: string title?: string cwd?: string @@ -535,6 +542,8 @@ export const openSessionTab = createAsyncThunk( firstUserMessage?: string isSubagent?: boolean isNonInteractive?: boolean + isRestorable?: boolean + codexDurability?: CodexDurabilityRef }, { dispatch, getState } ) => { @@ -556,13 +565,31 @@ export const openSessionTab = createAsyncThunk( const buildSessionMetadataByKey = (existing?: Tab['sessionMetadataByKey']) => mergeSessionMetadataByKey(existing, resolvedProvider, sessionId, sessionMetadataInput) + const shouldPersistSessionRef = isRestorable !== false + const liveTerminal = terminalId && localServerInstanceId + ? { terminalId, serverInstanceId: localServerInstanceId } + : undefined const desiredResumeContent = buildResumeContent({ sessionType: resolvedSessionType, sessionId, cwd, agentChatProviderSettings: providerSettings, + liveTerminal, }) + const terminalCodexDurability = resolvedProvider === 'codex' + && !shouldPersistSessionRef + && codexDurability?.candidate + ? codexDurability + : undefined + const desiredOpenContent = shouldPersistSessionRef || desiredResumeContent.kind !== 'terminal' + ? desiredResumeContent + : ({ + kind: 'terminal' as const, + mode: resolvedProvider, + initialCwd: cwd, + codexDurability: terminalCodexDurability, + }) const updateExistingTabMetadata = (tab: Tab | undefined) => { if (!tab) return @@ -574,7 +601,80 @@ export const openSessionTab = createAsyncThunk( })) } - const repairExistingTabLayout = (tab: Tab | undefined) => { + const targetSessionRef = shouldPersistSessionRef + ? sanitizeSessionRef({ provider: resolvedProvider, sessionId }) + : undefined + + const isTargetSessionRef = (sessionRef: unknown) => { + const sanitized = sanitizeSessionRef(sessionRef) + return Boolean( + sanitized + && sanitized.provider === resolvedProvider + && sanitized.sessionId === sessionId, + ) + } + + const hasNonEmptyResumeSessionId = (content: unknown) => ( + typeof (content as { resumeSessionId?: unknown }).resumeSessionId === 'string' + && ((content as { resumeSessionId: string }).resumeSessionId.trim().length > 0) + ) + + const paneHasOwnDurableIdentity = (content: unknown) => ( + Boolean(sanitizeSessionRef((content as { sessionRef?: unknown }).sessionRef)) + || hasNonEmptyResumeSessionId(content) + || Boolean((content as { codexDurability?: CodexDurabilityRef }).codexDurability?.durableThreadId) + || Boolean((content as { codexDurability?: CodexDurabilityRef }).codexDurability?.candidate?.candidateThreadId) + ) + + const collectLeafNodes = (node: PaneNode): Array<Extract<PaneNode, { type: 'leaf' }>> => { + if (node.type === 'leaf') return [node] + return [ + ...collectLeafNodes(node.children[0]), + ...collectLeafNodes(node.children[1]), + ] + } + + const isKnownCurrentLiveTerminal = (content: unknown) => { + if (!localServerInstanceId) return false + const terminalContent = content as { + kind?: unknown + terminalId?: unknown + serverInstanceId?: unknown + } + return terminalContent.kind === 'terminal' + && typeof terminalContent.terminalId === 'string' + && terminalContent.terminalId.length > 0 + && typeof terminalContent.serverInstanceId === 'string' + && terminalContent.serverInstanceId === localServerInstanceId + } + + const findStaleSinglePaneTabFallback = (): Tab | undefined => { + if (!targetSessionRef || desiredResumeContent.kind !== 'terminal') return undefined + + for (const tab of state.tabs.tabs) { + if (!isTargetSessionRef(tab.sessionRef)) continue + const layout = state.panes.layouts[tab.id] + if (!layout) continue + + const leaves = collectLeafNodes(layout) + if (leaves.length !== 1) continue + + const [{ content }] = leaves + if (content.kind !== 'terminal') continue + if (content.mode !== desiredResumeContent.mode) continue + if (paneHasOwnDurableIdentity(content)) continue + if (isKnownCurrentLiveTerminal(content)) continue + + return tab + } + + return undefined + } + + const repairExistingTabLayout = ( + tab: Tab | undefined, + options: { tabFallbackMissingPaneLocator?: boolean } = {}, + ) => { if (!tab) return const layout = state.panes.layouts[tab.id] if (!layout) return @@ -598,7 +698,16 @@ export const openSessionTab = createAsyncThunk( && resolvedProvider === 'claude' && content.resumeSessionId === sessionId ) - if (matchesExplicitSessionRef || matchesImplicitSessionRef) { + const matchesCodexDurability = ( + resolvedProvider === 'codex' + && content.kind === 'terminal' + && content.mode === 'codex' + && ( + content.codexDurability?.durableThreadId === sessionId + || content.codexDurability?.candidate?.candidateThreadId === sessionId + ) + ) + if (matchesExplicitSessionRef || matchesImplicitSessionRef || matchesCodexDurability) { matchingLeaves.push({ id: node.id, content }) } return @@ -609,13 +718,74 @@ export const openSessionTab = createAsyncThunk( visit(layout) - if (matchingLeaves.length !== 1) return - const [{ id: paneId, content }] = matchingLeaves + let selectedLeaves = matchingLeaves + if (selectedLeaves.length === 0 && options.tabFallbackMissingPaneLocator) { + const leaves = collectLeafNodes(layout) + if (leaves.length === 1) { + const [{ id, content }] = leaves + if ( + targetSessionRef + && desiredResumeContent.kind === 'terminal' + && content.kind === 'terminal' + && content.mode === desiredResumeContent.mode + && !paneHasOwnDurableIdentity(content) + && !isKnownCurrentLiveTerminal(content) + ) { + selectedLeaves = [{ id, content }] + } + } + } + + if (selectedLeaves.length !== 1) return + const [{ id: paneId, content }] = selectedLeaves + const paneOwnsTarget = matchingLeaves.some((leaf) => leaf.id === paneId) + const tabFallbackMissingPaneLocator = Boolean(options.tabFallbackMissingPaneLocator && !paneOwnsTarget) + + if ( + targetSessionRef + && desiredResumeContent.kind === 'terminal' + && content.kind === 'terminal' + && content.mode === desiredResumeContent.mode + && (paneOwnsTarget || tabFallbackMissingPaneLocator) + ) { + const existingSessionRef = sanitizeSessionRef(content.sessionRef) + const resumeSessionId = typeof content.resumeSessionId === 'string' + ? content.resumeSessionId.trim() + : '' + const hasDifferentSessionRef = Boolean( + existingSessionRef + && ( + existingSessionRef.provider !== targetSessionRef.provider + || existingSessionRef.sessionId !== targetSessionRef.sessionId + ), + ) + const hasDifferentResumeSessionId = Boolean(resumeSessionId && resumeSessionId !== sessionId) + if (hasDifferentSessionRef || hasDifferentResumeSessionId) return + + if ( + !existingSessionRef + || existingSessionRef.provider !== targetSessionRef.provider + || existingSessionRef.sessionId !== targetSessionRef.sessionId + ) { + dispatch(updatePaneContent({ + tabId: tab.id, + paneId, + content: { + ...content, + sessionRef: targetSessionRef, + }, + })) + } + return + } + if (content.kind === 'terminal' && content.terminalId) return - const needsRepair = desiredResumeContent.kind === 'agent-chat' - ? content.kind !== 'agent-chat' || content.provider !== desiredResumeContent.provider - : content.kind !== 'terminal' || content.mode !== desiredResumeContent.mode + const needsRepair = desiredResumeContent.kind === 'fresh-agent' + ? content.kind !== 'fresh-agent' || content.sessionType !== desiredResumeContent.sessionType + : desiredResumeContent.kind === 'agent-chat' + ? content.kind !== 'agent-chat' || content.provider !== desiredResumeContent.provider + : content.kind !== 'terminal' || content.mode !== desiredResumeContent.mode if (!needsRepair) return @@ -647,10 +817,12 @@ export const openSessionTab = createAsyncThunk( mode: resolvedProvider, codingCliProvider: resolvedProvider, initialCwd: cwd, - sessionRef: desiredResumeContent.kind === 'terminal' || desiredResumeContent.kind === 'agent-chat' + serverInstanceId: localServerInstanceId, + sessionRef: shouldPersistSessionRef && (desiredResumeContent.kind === 'terminal' || desiredResumeContent.kind === 'agent-chat') ? desiredResumeContent.sessionRef : undefined, - sessionMetadataByKey: buildSessionMetadataByKey(), + codexDurability: terminalCodexDurability, + sessionMetadataByKey: shouldPersistSessionRef ? buildSessionMetadataByKey() : undefined, })) dispatch(initLayout({ tabId, @@ -658,7 +830,9 @@ export const openSessionTab = createAsyncThunk( kind: 'terminal', mode: resolvedProvider, terminalId, - sessionRef: desiredResumeContent.kind === 'terminal' ? desiredResumeContent.sessionRef : undefined, + serverInstanceId: localServerInstanceId, + sessionRef: shouldPersistSessionRef && desiredResumeContent.kind === 'terminal' ? desiredResumeContent.sessionRef : undefined, + codexDurability: terminalCodexDurability, initialCwd: cwd, status: 'running', }, @@ -672,11 +846,18 @@ export const openSessionTab = createAsyncThunk( { provider: resolvedProvider, sessionId }, localServerInstanceId, ) - if (existingTabId) { - const existingTab = state.tabs.tabs.find((tab) => tab.id === existingTabId) - updateExistingTabMetadata(existingTab) - repairExistingTabLayout(existingTab) - dispatch(setActiveTab(existingTabId)) + const staleSinglePaneFallbackTab = existingTabId ? undefined : findStaleSinglePaneTabFallback() + const tabToOpen = existingTabId + ? state.tabs.tabs.find((tab) => tab.id === existingTabId) + : staleSinglePaneFallbackTab + if (tabToOpen) { + const selectedExistingTabId = existingTabId ?? tabToOpen.id + const usingStaleSinglePaneFallback = !existingTabId && staleSinglePaneFallbackTab?.id === tabToOpen.id + updateExistingTabMetadata(tabToOpen) + repairExistingTabLayout(tabToOpen, { + tabFallbackMissingPaneLocator: usingStaleSinglePaneFallback, + }) + dispatch(setActiveTab(selectedExistingTabId)) return } } @@ -708,12 +889,13 @@ export const openSessionTab = createAsyncThunk( mode: resolvedProvider, codingCliProvider: resolvedProvider, initialCwd: cwd, - sessionRef: desiredResumeContent.kind === 'terminal' ? desiredResumeContent.sessionRef : undefined, - sessionMetadataByKey: buildSessionMetadataByKey(), + sessionRef: shouldPersistSessionRef && desiredResumeContent.kind === 'terminal' ? desiredResumeContent.sessionRef : undefined, + codexDurability: terminalCodexDurability, + sessionMetadataByKey: shouldPersistSessionRef ? buildSessionMetadataByKey() : undefined, })) dispatch(initLayout({ tabId, - content: desiredResumeContent, + content: desiredOpenContent, })) } ) diff --git a/src/store/types.ts b/src/store/types.ts index 828985bd4..ea82158b4 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -20,6 +20,7 @@ import type { WorktreeGrouping, } from '@shared/settings' import type { CodingCliProviderName, TokenSummary, SessionLocator } from '@shared/ws-protocol' +import type { CodexDurabilityRef } from '@shared/codex-durability' export type { CodingCliProviderName } // TabMode includes 'shell' for regular terminals, plus all coding CLI providers @@ -57,6 +58,7 @@ export interface Tab { shell?: ShellType initialCwd?: string sessionRef?: SessionLocator + codexDurability?: CodexDurabilityRef serverInstanceId?: string resumeSessionId?: string // Legacy migration field; canonical durable identity lives in sessionRef sessionMetadataByKey?: Record<string, SessionListMetadata> @@ -77,6 +79,7 @@ export interface BackgroundTerminal { hasClients: boolean mode?: TabMode sessionRef?: SessionLocator + codexDurability?: CodexDurabilityRef } export interface CodingCliSession { @@ -96,6 +99,9 @@ export interface CodingCliSession { sourceFile?: string isSubagent?: boolean isNonInteractive?: boolean + isRunning?: boolean + runningTerminalId?: string + liveTerminalOnly?: boolean gitBranch?: string isDirty?: boolean tokenUsage?: TokenSummary diff --git a/test/e2e-browser/helpers/test-harness.ts b/test/e2e-browser/helpers/test-harness.ts index 7d08c17e7..07112084e 100644 --- a/test/e2e-browser/helpers/test-harness.ts +++ b/test/e2e-browser/helpers/test-harness.ts @@ -100,6 +100,14 @@ export class TestHarness { }) } + async receiveWsMessage(message: unknown): Promise<void> { + await this.page.evaluate((msg) => { + const harness = window.__FRESHELL_TEST_HARNESS__ + if (!harness) throw new Error('Test harness not installed') + harness.receiveWsMessage?.(msg as any) + }, message) + } + /** * Wait for specific text to appear in the terminal buffer. * Uses the xterm.js buffer API via the test harness (renderer-agnostic). diff --git a/test/e2e-browser/perf/audit-contract.ts b/test/e2e-browser/perf/audit-contract.ts index 57f2384ee..a13e958ae 100644 --- a/test/e2e-browser/perf/audit-contract.ts +++ b/test/e2e-browser/perf/audit-contract.ts @@ -5,7 +5,7 @@ export const AUDIT_PROFILE_IDS = ['desktop_local', 'mobile_restricted'] as const export const AUDIT_SCENARIO_IDS = [ 'auth-required-cold-boot', 'terminal-cold-boot', - 'agent-chat-cold-boot', + 'fresh-agent-cold-boot', 'sidebar-search-large-corpus', 'terminal-reconnect-backlog', 'offscreen-tab-selection', diff --git a/test/e2e-browser/perf/scenarios.ts b/test/e2e-browser/perf/scenarios.ts index 9de552143..157cf49ee 100644 --- a/test/e2e-browser/perf/scenarios.ts +++ b/test/e2e-browser/perf/scenarios.ts @@ -3,7 +3,7 @@ import type { AuditProfileId } from './profiles.js' export type AuditScenarioId = | 'auth-required-cold-boot' | 'terminal-cold-boot' - | 'agent-chat-cold-boot' + | 'fresh-agent-cold-boot' | 'sidebar-search-large-corpus' | 'terminal-reconnect-backlog' | 'offscreen-tab-selection' @@ -60,8 +60,8 @@ export const AUDIT_SCENARIOS: readonly AuditScenarioDefinition[] = [ buildUrl: ({ token }) => buildRootUrl(token), }, { - id: 'agent-chat-cold-boot', - description: 'Cold boot into the seeded long-history agent chat session until the surface is visible.', + id: 'fresh-agent-cold-boot', + description: 'Cold boot into the seeded long-history fresh-agent session until the surface is visible.', focusedReadyMilestone: 'agent_chat.surface_visible', allowedApiRouteIdsBeforeReady: ['/api/bootstrap', '/api/agent-sessions/:sessionId/timeline'], allowedWsTypesBeforeReady: ['hello', 'ready', 'sdk.session.snapshot', 'sdk.status', 'sdk.stream', 'sdk.assistant', 'sdk.result', 'sdk.error', 'sdk.exit'], diff --git a/test/e2e-browser/perf/seed-browser-storage.ts b/test/e2e-browser/perf/seed-browser-storage.ts index 022b06991..4ba9026ab 100644 --- a/test/e2e-browser/perf/seed-browser-storage.ts +++ b/test/e2e-browser/perf/seed-browser-storage.ts @@ -54,8 +54,8 @@ function baseSeed(tabsRaw: string, panesRaw: string): StorageSeed { } export function buildAgentChatBrowserStorageSeed(): StorageSeed { - const tabId = 'tab-agent-chat' - const paneId = 'pane-agent-chat' + const tabId = 'tab-fresh-agent' + const paneId = 'pane-fresh-agent' return baseSeed( buildTabsPayload({ @@ -63,8 +63,8 @@ export function buildAgentChatBrowserStorageSeed(): StorageSeed { tabs: [ { id: tabId, - title: 'Agent Chat Audit', - createRequestId: 'tab-agent-chat', + title: 'Fresh Agent Audit', + createRequestId: 'tab-fresh-agent', }, ], }), @@ -74,10 +74,11 @@ export function buildAgentChatBrowserStorageSeed(): StorageSeed { type: 'leaf', id: paneId, content: { - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', sessionId: VISIBLE_FIRST_LONG_HISTORY_SESSION_ID, - createRequestId: 'agent-chat-audit-create', + createRequestId: 'fresh-agent-audit-create', status: 'idle', resumeSessionId: VISIBLE_FIRST_LONG_HISTORY_SESSION_ID, }, @@ -88,7 +89,7 @@ export function buildAgentChatBrowserStorageSeed(): StorageSeed { }, paneTitles: { [tabId]: { - [paneId]: 'Agent Chat Audit', + [paneId]: 'Fresh Agent Audit', }, }, }), @@ -139,8 +140,8 @@ export function buildTerminalBrowserStorageSeed(): StorageSeed { export function buildOffscreenTabBrowserStorageSeed(): StorageSeed { const terminalTabId = 'tab-terminal' const terminalPaneId = 'pane-terminal' - const agentChatTabId = 'tab-heavy-agent-chat' - const agentChatPaneId = 'pane-heavy-agent-chat' + const agentChatTabId = 'tab-heavy-fresh-agent' + const agentChatPaneId = 'pane-heavy-fresh-agent' return baseSeed( buildTabsPayload({ @@ -153,8 +154,8 @@ export function buildOffscreenTabBrowserStorageSeed(): StorageSeed { }, { id: agentChatTabId, - title: 'Background Agent Chat', - createRequestId: 'tab-heavy-agent-chat', + title: 'Background Fresh Agent', + createRequestId: 'tab-heavy-fresh-agent', }, ], }), @@ -175,10 +176,11 @@ export function buildOffscreenTabBrowserStorageSeed(): StorageSeed { type: 'leaf', id: agentChatPaneId, content: { - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', sessionId: VISIBLE_FIRST_LONG_HISTORY_SESSION_ID, - createRequestId: 'agent-chat-heavy-create', + createRequestId: 'fresh-agent-heavy-create', status: 'idle', resumeSessionId: VISIBLE_FIRST_LONG_HISTORY_SESSION_ID, }, @@ -193,7 +195,7 @@ export function buildOffscreenTabBrowserStorageSeed(): StorageSeed { [terminalPaneId]: 'Terminal Audit', }, [agentChatTabId]: { - [agentChatPaneId]: 'Background Agent Chat', + [agentChatPaneId]: 'Background Fresh Agent', }, }, }), diff --git a/test/e2e-browser/specs/fresh-agent-mobile.spec.ts b/test/e2e-browser/specs/fresh-agent-mobile.spec.ts new file mode 100644 index 000000000..d0399c04f --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-mobile.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../helpers/fixtures.js' + +test.describe('Fresh Agent Mobile', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('mobile tab switcher and sidebar stay usable with a restored fresh-agent pane', async ({ freshellPage, page, harness, terminal }) => { + await terminal.waitForTerminal() + const tabId = await harness.getActiveTabId() + const layout = await harness.getPaneLayout(tabId!) + expect(layout?.type).toBe('leaf') + const paneId = layout.id as string + + await page.evaluate((currentPaneId: string) => { + window.__FRESHELL_TEST_HARNESS__?.setAgentChatNetworkEffectsSuppressed(currentPaneId, true) + }, paneId) + + await page.evaluate(({ currentTabId, currentPaneId }) => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'panes/updatePaneContent', + payload: { + tabId: currentTabId, + paneId: currentPaneId, + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-mobile', + sessionId: 'sdk-mobile', + resumeSessionId: '55555555-5555-4555-8555-555555555555', + status: 'idle', + settingsDismissed: true, + }, + }, + }) + }, { currentTabId: tabId, currentPaneId: paneId }) + + await harness.receiveWsMessage({ + type: 'sdk.created', + requestId: 'req-mobile', + sessionId: 'sdk-mobile', + }) + await harness.receiveWsMessage({ + type: 'sdk.session.init', + sessionId: 'sdk-mobile', + cliSessionId: '55555555-5555-4555-8555-555555555555', + model: 'claude-opus-4-6', + cwd: '/workspace/mobile', + }) + + await expect(page.getByRole('textbox', { name: 'Chat message input' })).toBeVisible() + + await page.getByRole('button', { name: /open tab switcher/i }).click() + await expect(page.getByRole('button', { name: /close tab switcher/i })).toBeVisible() + await page.getByRole('button', { name: /close tab switcher/i }).click() + + const hideSidebar = page.getByRole('button', { name: /hide sidebar/i }) + if (await hideSidebar.isVisible().catch(() => false)) { + await hideSidebar.click() + await expect(page.getByRole('button', { name: /show sidebar/i })).toBeVisible() + await page.getByRole('button', { name: /show sidebar/i }).click() + } + + await expect(page.getByRole('textbox', { name: 'Chat message input' })).toBeVisible() + }) +}) diff --git a/test/e2e-browser/specs/fresh-agent.spec.ts b/test/e2e-browser/specs/fresh-agent.spec.ts new file mode 100644 index 000000000..6f00c1b66 --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent.spec.ts @@ -0,0 +1,258 @@ +import { test, expect } from '../helpers/fixtures.js' + +async function enableClaudeAndCodex(page: any) { + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'connection/setAvailableClis', + payload: { claude: true, codex: true }, + }) + harness?.dispatch({ + type: 'settings/updateSettingsLocal', + payload: { + codingCli: { + enabledProviders: ['claude', 'codex'], + }, + }, + }) + }) +} + +async function openPanePicker(page: any) { + const termContainer = page.locator('.xterm').first() + await termContainer.click({ button: 'right' }) + await page.getByRole('menuitem', { name: /split horizontally/i }).click() + await expect(page.getByRole('toolbar', { name: /pane type picker/i })).toBeVisible({ timeout: 10_000 }) +} + +async function getActiveLeaf(harness: any) { + const tabId = await harness.getActiveTabId() + expect(tabId).toBeTruthy() + const layout = await harness.getPaneLayout(tabId!) + expect(layout?.type).toBe('leaf') + return { tabId: tabId!, paneId: layout.id as string } +} + +test.describe('Fresh Agent', () => { + test('pane picker shows Freshclaude and Freshcodex when their CLIs are enabled', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + await enableClaudeAndCodex(page) + + await openPanePicker(page) + await expect(page.getByRole('button', { name: /^Freshclaude$/i })).toBeVisible() + await expect(page.getByRole('button', { name: /^Freshcodex$/i })).toBeVisible() + }) + + test('freshclaude banners render through the fresh-agent pane surface and answer over WS', async ({ freshellPage, page, harness, terminal }) => { + await terminal.waitForTerminal() + const { tabId, paneId } = await getActiveLeaf(harness) + const sessionId = 'freshclaude-thread-1' + + await page.route(`**/api/fresh-agent/threads/freshclaude/claude/${sessionId}*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sessionType: 'freshclaude', + provider: 'claude', + threadId: sessionId, + sessionId, + revision: 1, + latestTurnId: null, + status: 'running', + capabilities: { + send: true, + interrupt: true, + approvals: true, + questions: true, + fork: false, + }, + settings: { + model: 'claude-opus-4-6', + permissionMode: 'default', + plugins: [], + }, + tokenUsage: { + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + costUsd: 0, + }, + pendingApprovals: [{ + requestId: 'perm-e2e', + toolName: 'Bash', + input: { command: 'echo hello-from-fresh-agent' }, + }], + pendingQuestions: [{ + requestId: 'question-e2e', + questions: [{ + header: 'Approve plan', + question: 'How should Claude proceed?', + options: [ + { label: 'Continue', description: 'Keep going' }, + { label: 'Stop', description: 'Pause the task' }, + ], + multiSelect: false, + }], + }], + turns: [], + extensions: { + claude: { + liveSessionId: sessionId, + cliSessionId: '33333333-3333-4333-8333-333333333333', + }, + }, + }), + }) + }) + + await page.evaluate(({ currentTabId, currentPaneId, currentSessionId }) => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'panes/updatePaneContent', + payload: { + tabId: currentTabId, + paneId: currentPaneId, + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-e2e-permission', + sessionId: currentSessionId, + resumeSessionId: currentSessionId, + status: 'idle', + settingsDismissed: true, + }, + }, + }) + }, { + currentTabId: tabId, + currentPaneId: paneId, + currentSessionId: sessionId, + }) + + const permissionBanner = page.getByRole('alert', { name: /permission request for bash/i }) + await expect(permissionBanner).toBeVisible() + await expect(permissionBanner).toContainText('echo hello-from-fresh-agent') + const questionBanner = page.getByRole('region', { name: /question from claude/i }) + await expect(questionBanner).toBeVisible() + await expect(questionBanner).toContainText('How should Claude proceed?') + + await harness.clearSentWsMessages() + await permissionBanner.getByRole('button', { name: /allow tool use/i }).click() + await questionBanner.getByRole('button', { name: 'Continue' }).click() + + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() + return { + permission: sent.find((msg: any) => msg?.type === 'freshAgent.approval.respond') ?? null, + question: sent.find((msg: any) => msg?.type === 'freshAgent.question.respond') ?? null, + } + }).toMatchObject({ + permission: { + type: 'freshAgent.approval.respond', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + requestId: 'perm-e2e', + decision: { + behavior: 'allow', + }, + }, + question: { + type: 'freshAgent.question.respond', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + requestId: 'question-e2e', + answers: { 'How should Claude proceed?': 'Continue' }, + }, + }) + }) + + test('browser user can create and resume Freshcodex with worktree, review, and fork metadata in the shared pane', async ({ freshellPage, page, harness, terminal, serverInfo }) => { + await terminal.waitForTerminal() + await enableClaudeAndCodex(page) + + await page.route(`${serverInfo.baseUrl}/api/fresh-agent/threads/freshcodex/codex/thread-codex*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex', + revision: 7, + status: 'idle', + summary: 'Freshcodex session', + capabilities: { send: false, interrupt: false, approvals: false, questions: false, fork: false }, + tokenUsage: { totalTokens: 42, inputTokens: 10, outputTokens: 32 }, + worktrees: [{ id: 'wt-1', path: '/tmp/worktree', branch: 'feature/fresh-agent' }], + diffs: [{ id: 'diff-1', title: 'README.md' }], + childThreads: [{ id: 'child-1', threadId: 'child-thread', origin: 'codex', title: 'Subagent' }], + extensions: { + codex: { + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }, + }, + turns: [{ + id: 'turn-1', + turnId: 'turn-1', + role: 'assistant', + summary: 'Codex transcript', + items: [{ id: 'item-1', kind: 'text', text: 'Codex transcript' }], + }], + }), + }) + }) + + await openPanePicker(page) + const tabId = await harness.getActiveTabId() + const activePaneId = await page.evaluate((currentTabId: string) => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + return state?.panes?.activePane?.[currentTabId] ?? null + }, tabId!) + expect(activePaneId).toBeTruthy() + await page.evaluate((currentPaneId: string) => { + window.__FRESHELL_TEST_HARNESS__?.setAgentChatNetworkEffectsSuppressed(currentPaneId, true) + }, activePaneId) + await page.getByRole('button', { name: /^Freshcodex$/i }).click() + await page.getByRole('option').first().click() + await expect(page.locator('[data-context="fresh-agent"]').getByText('Starting session', { exact: true }).first()).toBeVisible() + + await page.evaluate(({ currentTabId, currentPaneId }) => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'panes/updatePaneContent', + payload: { + tabId: currentTabId, + paneId: currentPaneId, + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-codex-browser', + sessionId: 'thread-codex', + resumeSessionId: 'thread-codex', + status: 'connected', + }, + }, + }) + }, { + currentTabId: tabId, + currentPaneId: activePaneId, + }) + + await expect(page.getByText('Freshcodex session')).toBeVisible() + await expect(page.getByText(/feature\/fresh-agent/)).toBeVisible() + await expect(page.getByText('README.md')).toBeVisible() + await expect(page.getByText('review-1')).toBeVisible() + await expect(page.getByText('pending')).toBeVisible() + await expect(page.getByText('thread-parent-1')).toBeVisible() + + await page.goto(`${serverInfo.baseUrl}/?token=${serverInfo.token}&e2e=1`) + await harness.waitForHarness() + await harness.waitForConnection() + await expect(page.getByText('Freshcodex session')).toBeVisible() + await expect(page.getByText(/feature\/fresh-agent/)).toBeVisible() + }) +}) diff --git a/test/e2e-browser/specs/multirow-tabs.spec.ts b/test/e2e-browser/specs/multirow-tabs.spec.ts new file mode 100644 index 000000000..996639c07 --- /dev/null +++ b/test/e2e-browser/specs/multirow-tabs.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../helpers/fixtures.js' + +test.describe('Multi-row tabs', () => { + async function openSettings(page: any) { + await page.getByRole('button', { name: /settings/i }).click() + await expect(page.getByRole('tab', { name: /^Appearance$/i })).toBeVisible({ timeout: 10_000 }) + } + + test('enables multi-row tabs via settings toggle', async ({ freshellPage: page }) => { + await openSettings(page) + + const toggle = page.getByRole('switch', { name: /multi-row tabs/i }) + await expect(toggle).toBeVisible({ timeout: 5_000 }) + await expect(toggle).not.toBeChecked() + await toggle.click() + await expect(toggle).toBeChecked() + }) + + test('multi-row mode applies flex-wrap to tab strip', async ({ freshellPage: page }) => { + await page.evaluate(() => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'settings/updateSettingsLocal', + payload: { panes: { multirowTabs: true } }, + }) + }) + + const tabStrip = page.getByTestId('tab-strip') + await expect(tabStrip).toBeVisible({ timeout: 5_000 }) + await expect(tabStrip).toHaveClass(/flex-wrap/) + await expect(tabStrip).toHaveClass(/max-h-32/) + }) + + test('single-row mode uses overflow-x-auto', async ({ freshellPage: page }) => { + const tabStrip = page.getByTestId('tab-strip') + await expect(tabStrip).toBeVisible({ timeout: 5_000 }) + await expect(tabStrip).toHaveClass(/overflow-x-auto/) + await expect(tabStrip).not.toHaveClass(/flex-wrap/) + }) +}) diff --git a/test/e2e-browser/specs/sidebar.spec.ts b/test/e2e-browser/specs/sidebar.spec.ts index d08b6f23e..3ebc6d05a 100644 --- a/test/e2e-browser/specs/sidebar.spec.ts +++ b/test/e2e-browser/specs/sidebar.spec.ts @@ -26,6 +26,61 @@ test.describe('Sidebar', () => { await expect(page.getByRole('button', { name: /hide sidebar/i })).toBeVisible() }) + test('sidebar reopen button stays fixed when overflowing tabs are scrolled', async ({ freshellPage, page }) => { + await page.setViewportSize({ width: 900, height: 700 }) + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + if (!harness) throw new Error('Freshell test harness is not installed') + for (let i = 0; i < 18; i += 1) { + harness.dispatch({ + type: 'tabs/addTab', + payload: { + id: `overflow-tab-${i}`, + createRequestId: `overflow-tab-${i}`, + title: `Overflow tab ${i}`, + mode: 'shell', + shell: 'system', + status: 'running', + }, + }) + } + }) + await page.waitForFunction(() => ( + window.__FRESHELL_TEST_HARNESS__?.getState()?.tabs?.tabs?.length ?? 0 + ) >= 18) + + const collapseButton = page.getByRole('button', { name: /hide sidebar/i }) + await collapseButton.click() + + const showButton = page.getByRole('button', { name: /show sidebar/i }) + await expect(showButton).toBeVisible({ timeout: 3_000 }) + + const tabBar = page.locator('[data-context="global"]').filter({ + has: page.getByRole('button', { name: /new shell tab/i }), + }).first() + const tabScroller = tabBar.locator('.overflow-x-auto').first() + await expect(tabScroller).toBeVisible() + await expect.poll(async () => tabScroller.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true) + + const before = await showButton.boundingBox() + expect(before).not.toBeNull() + + const scrollLeft = await tabScroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + return element.scrollLeft + }) + expect(scrollLeft).toBeGreaterThan(0) + + const after = await showButton.boundingBox() + const scrollerBox = await tabScroller.boundingBox() + expect(after).not.toBeNull() + expect(scrollerBox).not.toBeNull() + expect(after!.x).toBeCloseTo(before!.x, 0) + expect(after!.x).toBeGreaterThanOrEqual(0) + expect(after!.x + after!.width).toBeLessThanOrEqual(scrollerBox!.x) + }) + test('sidebar shows navigation buttons', async ({ freshellPage, page }) => { // Nav buttons have title attributes like "Settings (Ctrl+B ,)", "Tabs (Ctrl+B A)", etc. // Playwright matches title as accessible name for buttons with no text/aria-label. diff --git a/test/e2e-browser/specs/tab-recency-sync.spec.ts b/test/e2e-browser/specs/tab-recency-sync.spec.ts new file mode 100644 index 000000000..a099fe2fc --- /dev/null +++ b/test/e2e-browser/specs/tab-recency-sync.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../helpers/fixtures.js' + +test.describe('Tab recency sync', () => { + test('rapid terminal input sends a bounded number of tab activity pushes', async ({ + freshellPage, + harness, + terminal, + }) => { + await terminal.waitForTerminal() + await terminal.waitForPrompt() + + await freshellPage.waitForTimeout(1000) + await harness.clearSentWsMessages() + await freshellPage.waitForTimeout(250) + expect((await harness.getSentWsMessages()).filter((message: any) => message?.type === 'tabs.sync.push')).toHaveLength(0) + + const startBucket = await freshellPage.evaluate(() => Math.floor(Date.now() / 60_000) * 60_000) + await terminal.typeInTerminal('aaaaaaaaaaaaaaaaaaaaaaaa') + await freshellPage.waitForTimeout(500) + const endBucket = await freshellPage.evaluate(() => Math.floor(Date.now() / 60_000) * 60_000) + + const messages = await harness.getSentWsMessages() + const pushes = messages.filter((message: any) => message?.type === 'tabs.sync.push') + const allowedActivityPushes = endBucket > startBucket ? 2 : 1 + + expect(pushes.length).toBeLessThanOrEqual(allowedActivityPushes) + const updatedAtBuckets = pushes + .map((push: any) => push.records?.[0]?.updatedAt) + .filter((updatedAt: any): updatedAt is number => typeof updatedAt === 'number') + expect(updatedAtBuckets).toHaveLength(pushes.length) + expect(new Set(updatedAtBuckets).size).toBeLessThanOrEqual(allowedActivityPushes) + for (const updatedAt of updatedAtBuckets) { + expect(updatedAt % 60_000).toBe(0) + } + }) +}) diff --git a/test/e2e/agent-chat-capability-settings-flow.test.tsx b/test/e2e/agent-chat-capability-settings-flow.test.tsx index bea5617eb..34d9c9f12 100644 --- a/test/e2e/agent-chat-capability-settings-flow.test.tsx +++ b/test/e2e/agent-chat-capability-settings-flow.test.tsx @@ -1,13 +1,14 @@ import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from 'vitest' +import { useRef } from 'react' import { configureStore } from '@reduxjs/toolkit' import { Provider, useSelector } from 'react-redux' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import AgentChatView from '@/components/agent-chat/AgentChatView' import agentChatReducer from '@/store/agentChatSlice' -import panesReducer, { initLayout } from '@/store/panesSlice' +import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' -import type { AgentChatPaneContent } from '@/store/paneTypes' +import type { AgentChatPaneContent, FreshAgentPaneContent } from '@/store/paneTypes' import { AGENT_CHAT_CAPABILITY_CACHE_TTL_MS, AGENT_CHAT_PROVIDER_DEFAULT_OPTION_VALUE, @@ -47,7 +48,10 @@ vi.mock('@/store/settingsThunks', () => ({ }), })) -function makeStore(preloadedAgentChat: Record<string, unknown> = {}) { +function makeStore( + preloadedAgentChat: Record<string, unknown> = {}, + paneContent?: AgentChatPaneContent, +) { return configureStore({ reducer: { agentChat: agentChatReducer, @@ -73,6 +77,24 @@ function makeStore(preloadedAgentChat: Record<string, unknown> = {}) { capabilitiesByProvider: {}, ...preloadedAgentChat, }, + panes: { + layouts: paneContent + ? { + t1: { + type: 'leaf' as const, + id: 'p1', + content: paneContent, + }, + } + : {}, + activePane: paneContent ? { t1: 'p1' } : {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, }, }) } @@ -89,15 +111,15 @@ function renderStoreBackedPane( paneContent: AgentChatPaneContent, preloadedAgentChat: Record<string, unknown> = {}, ) { - const store = makeStore(preloadedAgentChat) - store.dispatch(initLayout({ tabId: 't1', paneId: 'p1', content: paneContent })) + const store = makeStore(preloadedAgentChat, paneContent) function Wrapper() { + const lastLegacyContentRef = useRef(paneContent) const root = useSelector((state: ReturnType<typeof store.getState>) => state.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined - if (!content) return null + const content = root?.type === 'leaf' + ? coerceLegacyAgentChatPaneContent(root.content, lastLegacyContentRef.current) + : lastLegacyContentRef.current + lastLegacyContentRef.current = content return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -110,12 +132,44 @@ function renderStoreBackedPane( return store } +function coerceLegacyAgentChatPaneContent( + content: unknown, + fallback: AgentChatPaneContent, +): AgentChatPaneContent { + if (content && typeof content === 'object' && (content as { kind?: unknown }).kind === 'agent-chat') { + return content as AgentChatPaneContent + } + if (content && typeof content === 'object' && (content as { kind?: unknown }).kind === 'fresh-agent') { + const freshContent = content as FreshAgentPaneContent + return { + ...fallback, + sessionId: freshContent.sessionId, + createRequestId: freshContent.createRequestId, + status: freshContent.status, + resumeSessionId: freshContent.resumeSessionId, + sessionRef: freshContent.sessionRef, + serverInstanceId: freshContent.serverInstanceId, + restoreError: freshContent.restoreError, + initialCwd: freshContent.initialCwd, + createError: freshContent.createError, + modelSelection: freshContent.modelSelection, + permissionMode: freshContent.permissionMode, + effort: freshContent.effort, + plugins: freshContent.plugins, + settingsDismissed: freshContent.settingsDismissed, + kind: 'agent-chat', + provider: freshContent.sessionType === 'kilroy' ? 'kilroy' : 'freshclaude', + } + } + return fallback +} + function getRenderedPaneContent(store: ReturnType<typeof makeStore>): AgentChatPaneContent { const root = store.getState().panes.layouts.t1 - if (root?.type !== 'leaf' || root.content.kind !== 'agent-chat') { + if (root?.type !== 'leaf') { throw new Error('Expected an agent chat pane at t1/p1') } - return root.content + return coerceLegacyAgentChatPaneContent(root.content, BASE_PANE) } function freshFetchedAt(): number { diff --git a/test/e2e/agent-chat-restore-flow.test.tsx b/test/e2e/agent-chat-restore-flow.test.tsx index 18fb21ea8..8833b4fa6 100644 --- a/test/e2e/agent-chat-restore-flow.test.tsx +++ b/test/e2e/agent-chat-restore-flow.test.tsx @@ -7,7 +7,7 @@ import agentChatReducer from '@/store/agentChatSlice' import panesReducer, { initLayout } from '@/store/panesSlice' import settingsReducer from '@/store/settingsSlice' import tabsReducer from '@/store/tabsSlice' -import type { AgentChatPaneContent, PaneNode } from '@/store/paneTypes' +import type { AgentChatPaneContent, FreshAgentPaneContent, PaneContent, PaneNode } from '@/store/paneTypes' import type { Tab } from '@/store/types' import { handleSdkMessage } from '@/lib/sdk-message-handler' @@ -114,13 +114,28 @@ function findLeaf(node: PaneNode, paneId: string): Extract<PaneNode, { type: 'le return findLeaf(node.children[0], paneId) || findLeaf(node.children[1], paneId) } +function normalizeAgentChatPaneContent(content: PaneContent | undefined): AgentChatPaneContent | undefined { + if (!content) return undefined + if (content.kind === 'agent-chat') return content + if (content.kind !== 'fresh-agent') return undefined + if (content.sessionType !== 'freshclaude' && content.sessionType !== 'kilroy') return undefined + + const migrated: FreshAgentPaneContent = content + return { + ...migrated, + kind: 'agent-chat', + provider: migrated.sessionType, + } +} + function ReactivePane({ store }: { store: ReturnType<typeof makeStore> }) { - const content = useSelector((s: ReturnType<typeof store.getState>) => { + const rawContent = useSelector((s: ReturnType<typeof store.getState>) => { const root = s.panes.layouts.t1 if (!root) return undefined const leaf = findLeaf(root, 'p1') - return leaf?.content.kind === 'agent-chat' ? leaf.content : undefined + return leaf?.content }) + const content = normalizeAgentChatPaneContent(rawContent) if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> @@ -159,12 +174,14 @@ describe('agent chat restore flow', () => { content: pane, })) + const durableSessionId = '00000000-0000-4000-8000-000000000111' + getAgentTimelinePage.mockResolvedValue({ - sessionId: 'cli-session-1', + sessionId: durableSessionId, items: [ { turnId: 'turn-2', - sessionId: 'cli-session-1', + sessionId: durableSessionId, role: 'assistant', summary: 'Recent summary', timestamp: '2026-03-10T10:01:00.000Z', @@ -174,7 +191,7 @@ describe('agent chat restore flow', () => { revision: 2, bodies: { 'turn-2': { - sessionId: 'cli-session-1', + sessionId: durableSessionId, turnId: 'turn-2', message: { role: 'assistant', @@ -197,7 +214,7 @@ describe('agent chat restore flow', () => { sessionId: 'sdk-sess-1', latestTurnId: 'turn-2', status: 'running', - timelineSessionId: 'cli-session-1', + timelineSessionId: durableSessionId, revision: 2, streamingActive: true, streamingText: 'partial reply', @@ -209,7 +226,7 @@ describe('agent chat restore flow', () => { await waitFor(() => { expect(getAgentTimelinePage).toHaveBeenCalledWith( - 'cli-session-1', + durableSessionId, expect.objectContaining({ priority: 'visible', includeBodies: true }), expect.anything(), ) @@ -222,11 +239,19 @@ describe('agent chat restore flow', () => { await waitFor(() => { const root = store.getState().panes.layouts.t1 const leaf = root && findLeaf(root, 'p1') - expect(leaf?.content.kind === 'agent-chat' ? leaf.content.resumeSessionId : undefined).toBe('cli-session-1') + const content = normalizeAgentChatPaneContent(leaf?.content) + expect(content?.sessionRef).toEqual({ + provider: 'claude', + sessionId: durableSessionId, + }) const tab = store.getState().tabs.tabs.find((entry) => entry.id === 't1') - expect(tab?.resumeSessionId).toBe('cli-session-1') - expect(tab?.sessionMetadataByKey?.['claude:cli-session-1']).toEqual(expect.objectContaining({ + expect(tab?.sessionRef).toEqual({ + provider: 'claude', + sessionId: durableSessionId, + }) + expect(tab?.resumeSessionId).toBeUndefined() + expect(tab?.sessionMetadataByKey?.[`claude:${durableSessionId}`]).toEqual(expect.objectContaining({ sessionType: 'freshclaude', firstUserMessage: 'Continue from the old tab', })) @@ -381,7 +406,8 @@ describe('agent chat restore flow', () => { await waitFor(() => { const root = store.getState().panes.layouts.t1 const leaf = root && findLeaf(root, 'p1') - expect(leaf?.content.kind === 'agent-chat' ? leaf.content.sessionId : undefined).toBe('sdk-reconnected-1') + const content = normalizeAgentChatPaneContent(leaf?.content) + expect(content?.sessionId).toBe('sdk-reconnected-1') }) act(() => { @@ -391,7 +417,7 @@ describe('agent chat restore flow', () => { expect(wsHarness.sdkCreates()).toHaveLength(2) const root = store.getState().panes.layouts.t1 const leaf = root && findLeaf(root, 'p1') - expect(leaf?.content.kind === 'agent-chat' ? leaf.content : undefined).toEqual(expect.objectContaining({ + expect(normalizeAgentChatPaneContent(leaf?.content)).toEqual(expect.objectContaining({ sessionId: 'sdk-reconnected-1', status: 'connected', })) diff --git a/test/e2e/agent-chat-resume-history-flow.test.tsx b/test/e2e/agent-chat-resume-history-flow.test.tsx index 800e4724a..a4175a84a 100644 --- a/test/e2e/agent-chat-resume-history-flow.test.tsx +++ b/test/e2e/agent-chat-resume-history-flow.test.tsx @@ -1,28 +1,23 @@ -import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest' +import { describe, it, expect, vi, afterEach } from 'vitest' import { render, screen, cleanup, act, waitFor, fireEvent } from '@testing-library/react' import { configureStore } from '@reduxjs/toolkit' import { Provider, useSelector } from 'react-redux' -import AgentChatView from '@/components/agent-chat/AgentChatView' +import FreshAgentView from '@/components/fresh-agent/FreshAgentView' +import freshAgentReducer from '@/store/freshAgentSlice' import agentChatReducer from '@/store/agentChatSlice' import panesReducer, { initLayout } from '@/store/panesSlice' -import tabsReducer, { addTab } from '@/store/tabsSlice' +import tabsReducer from '@/store/tabsSlice' import settingsReducer from '@/store/settingsSlice' -import type { AgentChatPaneContent, PaneNode } from '@/store/paneTypes' -import { handleSdkMessage } from '@/lib/sdk-message-handler' -import { sessionMetadataKey } from '@/lib/session-metadata' - -beforeAll(() => { - Element.prototype.scrollIntoView = vi.fn() -}) +import type { FreshAgentPaneContent, PaneNode } from '@/store/paneTypes' const wsSend = vi.fn() -const getAgentTimelinePage = vi.fn() -const getAgentTurnBody = vi.fn() -const setSessionMetadata = vi.fn(() => Promise.resolve(undefined)) +const wsOnMessage = vi.fn(() => () => {}) +const getFreshAgentThreadSnapshot = vi.fn() vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: wsSend, + onMessage: wsOnMessage, onReconnect: vi.fn(() => vi.fn()), }), })) @@ -31,9 +26,7 @@ vi.mock('@/lib/api', async () => { const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api') return { ...actual, - getAgentTimelinePage: (...args: unknown[]) => getAgentTimelinePage(...args), - getAgentTurnBody: (...args: unknown[]) => getAgentTurnBody(...args), - setSessionMetadata: (...args: unknown[]) => setSessionMetadata(...args), + getFreshAgentThreadSnapshot: (...args: unknown[]) => getFreshAgentThreadSnapshot(...args), } }) @@ -41,6 +34,7 @@ function makeStore() { return configureStore({ reducer: { agentChat: agentChatReducer, + freshAgent: freshAgentReducer, panes: panesReducer, tabs: tabsReducer, settings: settingsReducer, @@ -58,96 +52,34 @@ function ReactivePane({ store }: { store: ReturnType<typeof makeStore> }) { const root = s.panes.layouts.t1 if (!root) return undefined const leaf = findLeaf(root, 'p1') - return leaf?.content.kind === 'agent-chat' ? leaf.content : undefined + return leaf?.content.kind === 'fresh-agent' ? leaf.content : undefined }) if (!content) return null - return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> + return <FreshAgentView tabId="t1" paneId="p1" paneContent={content} /> } -describe('agent chat resume history flow', () => { +describe('fresh-agent resume history flow', () => { afterEach(() => { cleanup() - wsSend.mockClear() - getAgentTimelinePage.mockReset() - getAgentTurnBody.mockReset() - setSessionMetadata.mockClear() + wsSend.mockReset() + wsOnMessage.mockReset() + wsOnMessage.mockImplementation(() => () => {}) + getFreshAgentThreadSnapshot.mockReset() }) - it('hydrates durable history after sdk.created for a resumed create', async () => { - const canonicalSessionId = '00000000-0000-4000-8000-000000000225' - getAgentTimelinePage.mockResolvedValue({ - sessionId: canonicalSessionId, - items: [ - { - turnId: 'turn-older-user', - sessionId: canonicalSessionId, - role: 'user', - summary: 'Older question', - timestamp: '2026-03-10T10:00:00.000Z', - }, - { - turnId: 'turn-older-assistant', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Older answer', - timestamp: '2026-03-10T10:00:20.000Z', - }, - { - turnId: 'turn-new-user', - sessionId: canonicalSessionId, - role: 'user', - summary: 'New prompt', - timestamp: '2026-03-10T10:01:00.000Z', - }, - { - turnId: 'turn-new-assistant', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'New reply', - timestamp: '2026-03-10T10:01:20.000Z', - }, - ], - nextCursor: null, + it('creates a resumed freshclaude pane through freshAgent.create and hydrates from the fresh-agent snapshot', async () => { + const canonicalSessionId = '00000000-0000-4000-8000-000000000441' + getFreshAgentThreadSnapshot.mockResolvedValue({ revision: 4, - bodies: { - 'turn-older-user': { - sessionId: canonicalSessionId, - turnId: 'turn-older-user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Older durable question' }], - timestamp: '2026-03-10T10:00:00.000Z', - }, - }, - 'turn-older-assistant': { - sessionId: canonicalSessionId, - turnId: 'turn-older-assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Older durable answer' }], - timestamp: '2026-03-10T10:00:20.000Z', - }, - }, - 'turn-new-user': { - sessionId: canonicalSessionId, - turnId: 'turn-new-user', - message: { - role: 'user', - content: [{ type: 'text', text: 'New live prompt' }], - timestamp: '2026-03-10T10:01:00.000Z', - }, - }, - 'turn-new-assistant': { - sessionId: canonicalSessionId, - turnId: 'turn-new-assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Hydrated from durable history' }], - timestamp: '2026-03-10T10:01:20.000Z', - }, - }, - }, + status: 'idle', + summary: 'Hydrated fresh-agent history', + capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: false }, + turns: [{ + id: 'turn-new-assistant', + role: 'assistant', + items: [{ id: 'item-1', kind: 'text', text: 'Hydrated from durable history' }], + }], }) const store = makeStore() @@ -155,15 +87,13 @@ describe('agent chat resume history flow', () => { tabId: 't1', paneId: 'p1', content: { - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', createRequestId: 'req-resume', status: 'creating', - sessionRef: { - provider: 'claude', - sessionId: canonicalSessionId, - }, - }, + resumeSessionId: canonicalSessionId, + } satisfies FreshAgentPaneContent, })) render( @@ -173,220 +103,75 @@ describe('agent chat resume history flow', () => { ) expect(wsSend).toHaveBeenCalledWith(expect.objectContaining({ - type: 'sdk.create', + type: 'freshAgent.create', requestId: 'req-resume', + sessionType: 'freshclaude', + provider: 'claude', resumeSessionId: canonicalSessionId, })) + const onMessage = wsOnMessage.mock.calls[0]?.[0] + expect(onMessage).toBeTypeOf('function') + act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.created', + onMessage({ + type: 'freshAgent.created', requestId: 'req-resume', sessionId: 'sdk-sess-1', - }) - handleSdkMessage(store.dispatch, { - type: 'sdk.session.snapshot', - sessionId: 'sdk-sess-1', - latestTurnId: 'turn-2', - status: 'idle', - timelineSessionId: canonicalSessionId, - revision: 4, + sessionType: 'freshclaude', + provider: 'claude', + runtimeProvider: 'claude', }) }) - expect(screen.getByText(/restoring session/i)).toBeInTheDocument() - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenCalledWith( - canonicalSessionId, - expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 4 }), + expect(getFreshAgentThreadSnapshot).toHaveBeenCalledWith( + 'freshclaude', + 'claude', + 'sdk-sess-1', expect.objectContaining({ signal: expect.any(AbortSignal) }), ) }) - expect(getAgentTurnBody).not.toHaveBeenCalled() - await waitFor(() => { - const renderedMessages = screen.getAllByRole('article') - .map((node) => node.textContent?.replace(/\s+/g, ' ').trim()) - expect(renderedMessages).toEqual([ - 'Older durable question', - 'Older durable answer', - 'New live prompt', - 'Hydrated from durable history', - ]) + expect(screen.getByText('Hydrated fresh-agent history')).toBeInTheDocument() + expect(screen.getByText('Hydrated from durable history')).toBeInTheDocument() }) - expect(screen.queryByText(/restoring session/i)).not.toBeInTheDocument() }) - it('upgrades a live-only named resume in place when a later timeline page exposes the canonical durable id', async () => { - const canonicalSessionId = '00000000-0000-4000-8000-000000000777' - getAgentTurnBody.mockResolvedValue({ - sessionId: canonicalSessionId, - turnId: 'turn-durable-1', - message: { - role: 'user', - content: [{ type: 'text', text: 'Older durable question' }], - timestamp: '2026-03-10T10:00:00.000Z', - }, - }) - getAgentTimelinePage - .mockResolvedValueOnce({ - sessionId: 'sdk-live-only', - items: [ - { - turnId: 'turn-live-1', - sessionId: 'sdk-live-only', - role: 'assistant', - summary: 'Live-only reply', - timestamp: '2026-03-10T10:01:20.000Z', - }, - ], - nextCursor: null, - revision: 1, - bodies: { - 'turn-live-1': { - sessionId: 'sdk-live-only', - turnId: 'turn-live-1', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Live-only full body' }], - timestamp: '2026-03-10T10:01:20.000Z', - }, - }, - }, - }) - .mockResolvedValueOnce({ - sessionId: canonicalSessionId, - items: [ - { - turnId: 'turn-live-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Post-watermark live delta', - timestamp: '2026-03-10T10:01:40.000Z', - }, - { - turnId: 'turn-durable-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Older durable answer', - timestamp: '2026-03-10T10:00:20.000Z', - }, - { - turnId: 'turn-durable-1', - sessionId: canonicalSessionId, - role: 'user', - summary: 'Older durable question', - timestamp: '2026-03-10T10:00:00.000Z', - }, - ], - nextCursor: null, - revision: 2, - bodies: { - 'turn-durable-2': { - sessionId: canonicalSessionId, - turnId: 'turn-durable-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Older durable answer' }], - timestamp: '2026-03-10T10:00:20.000Z', - }, - }, - 'turn-live-2': { - sessionId: canonicalSessionId, - turnId: 'turn-live-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Post-watermark live delta' }], - timestamp: '2026-03-10T10:01:40.000Z', - }, - }, + it('restores an existing freshclaude pane by reading the canonical durable snapshot instead of sending sdk.attach', async () => { + getFreshAgentThreadSnapshot.mockResolvedValue({ + revision: 5, + status: 'idle', + summary: 'Recovered durable history', + capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: false }, + turns: [ + { + id: 'turn-durable-1', + role: 'user', + items: [{ id: 'item-1', kind: 'text', text: 'Recovered durable question' }], }, - }) - .mockResolvedValueOnce({ - sessionId: canonicalSessionId, - items: [ - { - turnId: 'turn-live-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Post-watermark live delta', - timestamp: '2026-03-10T10:01:40.000Z', - }, - { - turnId: 'turn-durable-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Older durable answer', - timestamp: '2026-03-10T10:00:20.000Z', - }, - { - turnId: 'turn-durable-1', - sessionId: canonicalSessionId, - role: 'user', - summary: 'Older durable question', - timestamp: '2026-03-10T10:00:00.000Z', - }, - ], - nextCursor: null, - revision: 3, - bodies: { - 'turn-durable-1': { - sessionId: canonicalSessionId, - turnId: 'turn-durable-1', - message: { - role: 'user', - content: [{ type: 'text', text: 'Older durable question' }], - timestamp: '2026-03-10T10:00:00.000Z', - }, - }, - 'turn-durable-2': { - sessionId: canonicalSessionId, - turnId: 'turn-durable-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Older durable answer' }], - timestamp: '2026-03-10T10:00:20.000Z', - }, - }, - 'turn-live-2': { - sessionId: canonicalSessionId, - turnId: 'turn-live-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Post-watermark live delta' }], - timestamp: '2026-03-10T10:01:40.000Z', - }, - }, + { + id: 'turn-durable-2', + role: 'assistant', + items: [{ id: 'item-2', kind: 'text', text: 'Recovered durable answer' }], }, - }) + ], + }) + const canonicalSessionId = '00000000-0000-4000-8000-000000000778' const store = makeStore() - store.dispatch(addTab({ - id: 't1', - title: 'FreshClaude Tab', - mode: 'claude', - status: 'running', - createRequestId: 'req-live-only', - resumeSessionId: 'named-resume', - codingCliProvider: 'claude', - sessionMetadataByKey: { - [sessionMetadataKey('claude', 'named-resume')]: { - sessionType: 'freshclaude', - firstUserMessage: 'Original named resume prompt', - }, - }, - })) store.dispatch(initLayout({ tabId: 't1', paneId: 'p1', content: { - kind: 'agent-chat', - provider: 'freshclaude', - createRequestId: 'req-live-only', - status: 'creating', - resumeSessionId: 'named-resume', - }, + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-restart', + sessionId: canonicalSessionId, + resumeSessionId: canonicalSessionId, + status: 'idle', + } satisfies FreshAgentPaneContent, })) render( @@ -395,160 +180,45 @@ describe('agent chat resume history flow', () => { </Provider>, ) - act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.created', - requestId: 'req-live-only', - sessionId: 'sdk-live-only', - }) - handleSdkMessage(store.dispatch, { - type: 'sdk.session.snapshot', - sessionId: 'sdk-live-only', - latestTurnId: 'turn-live-1', - status: 'idle', - revision: 1, - }) - }) - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenCalledWith( - 'sdk-live-only', - expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 1 }), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ) - }) - expect(await screen.findByText('Live-only full body')).toBeInTheDocument() - - act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.assistant', - sessionId: 'sdk-live-only', - content: [{ type: 'text', text: 'Post-watermark live delta' }], - }) - }) - expect(screen.getByText('Post-watermark live delta')).toBeInTheDocument() - - act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.session.snapshot', - sessionId: 'sdk-live-only', - latestTurnId: 'turn-live-2', - status: 'idle', - revision: 2, - }) - }) - - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenNthCalledWith( - 2, - 'sdk-live-only', - expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 2 }), + expect(getFreshAgentThreadSnapshot).toHaveBeenCalledWith( + 'freshclaude', + 'claude', + canonicalSessionId, expect.objectContaining({ signal: expect.any(AbortSignal) }), ) }) - - await waitFor(() => { - expect(screen.getByText('Post-watermark live delta')).toBeInTheDocument() - expect(screen.getByText('Older durable answer')).toBeInTheDocument() - expect(screen.getByText('Older durable question')).toBeInTheDocument() - }) - expect(screen.queryByText('Live-only full body')).not.toBeInTheDocument() - expect(screen.getAllByText('Post-watermark live delta')).toHaveLength(1) - - const pane = findLeaf(store.getState().panes.layouts.t1!, 'p1') - expect(pane?.content.kind === 'agent-chat' ? pane.content.sessionRef : undefined).toEqual({ - provider: 'claude', - sessionId: canonicalSessionId, - }) - const tab = store.getState().tabs.tabs.find((entry) => entry.id === 't1') - expect(tab?.resumeSessionId).toBeUndefined() - expect(tab?.sessionRef).toEqual({ - provider: 'claude', - sessionId: canonicalSessionId, - }) - expect(tab?.sessionMetadataByKey).toEqual({ - [sessionMetadataKey('claude', canonicalSessionId)]: { - sessionType: 'freshclaude', - firstUserMessage: 'Original named resume prompt', - }, - }) - - const expandButtons = screen.getAllByLabelText('Expand turn') - fireEvent.click(expandButtons[0]!) - expect(getAgentTurnBody).toHaveBeenCalledWith( - canonicalSessionId, - 'turn-durable-1', - expect.objectContaining({ signal: expect.any(AbortSignal), revision: 2 }), - ) await waitFor(() => { - const renderedMessages = screen.getAllByRole('article') - .map((node) => node.textContent?.replace(/\s+/g, ' ').trim()) - expect(renderedMessages).toContain('Older durable question') - }) - - act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.session.snapshot', - sessionId: 'sdk-live-only', - latestTurnId: 'turn-live-2', - status: 'idle', - timelineSessionId: canonicalSessionId, - revision: 3, - }) - }) - - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenNthCalledWith( - 3, - canonicalSessionId, - expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 3 }), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ) + expect(screen.getByText('Recovered durable question')).toBeInTheDocument() + expect(screen.getByText('Recovered durable answer')).toBeInTheDocument() }) + expect(wsSend).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'sdk.attach' })) }) - it('restores a persisted pane through the canonical durable id after restart when the sdk session id is stale, then immediately recovers', async () => { - const canonicalSessionId = '00000000-0000-4000-8000-000000000778' - getAgentTimelinePage.mockResolvedValue({ - sessionId: canonicalSessionId, - items: [ - { - turnId: 'turn-durable-1', - sessionId: canonicalSessionId, - role: 'user', - summary: 'Recovered durable question', - timestamp: '2026-03-10T10:00:00.000Z', - }, - { - turnId: 'turn-durable-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Recovered durable answer', - timestamp: '2026-03-10T10:00:20.000Z', - }, - ], - nextCursor: null, - revision: 5, - bodies: { - 'turn-durable-1': { - sessionId: canonicalSessionId, - turnId: 'turn-durable-1', - message: { - role: 'user', - content: [{ type: 'text', text: 'Recovered durable question' }], - timestamp: '2026-03-10T10:00:00.000Z', - }, - }, - 'turn-durable-2': { - sessionId: canonicalSessionId, - turnId: 'turn-durable-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Recovered durable answer' }], - timestamp: '2026-03-10T10:00:20.000Z', - }, - }, - }, + it('answers freshclaude approvals and questions through the fresh-agent transport', async () => { + getFreshAgentThreadSnapshot.mockResolvedValue({ + revision: 1, + status: 'running', + summary: 'Approval flow', + capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: false }, + pendingApprovals: [{ + requestId: 'approval-1', + toolName: 'Bash', + input: { command: 'echo hello-from-fresh-agent' }, + }], + pendingQuestions: [{ + requestId: 'question-1', + questions: [{ + header: 'Approve plan', + question: 'How should Claude proceed?', + options: [ + { label: 'Continue', description: 'Keep going' }, + { label: 'Stop', description: 'Pause the task' }, + ], + multiSelect: false, + }], + }], + turns: [], }) const store = makeStore() @@ -556,16 +226,14 @@ describe('agent chat resume history flow', () => { tabId: 't1', paneId: 'p1', content: { - kind: 'agent-chat', - provider: 'freshclaude', - createRequestId: 'req-restart', - sessionId: 'sdk-stale-778', - sessionRef: { - provider: 'claude', - sessionId: canonicalSessionId, - }, + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-approval', + sessionId: 'freshclaude-session-1', + resumeSessionId: 'freshclaude-session-1', status: 'idle', - }, + } satisfies FreshAgentPaneContent, })) render( @@ -575,60 +243,29 @@ describe('agent chat resume history flow', () => { ) await waitFor(() => { - expect(wsSend).toHaveBeenCalledWith({ - type: 'sdk.attach', - sessionId: 'sdk-stale-778', - resumeSessionId: canonicalSessionId, - }) - }) - wsSend.mockClear() - - act(() => { - handleSdkMessage(store.dispatch, { - type: 'sdk.session.snapshot', - sessionId: 'sdk-stale-778', - latestTurnId: 'turn-durable-2', - status: 'idle', - timelineSessionId: canonicalSessionId, - revision: 5, - }) - handleSdkMessage(store.dispatch, { - type: 'sdk.error', - sessionId: 'sdk-stale-778', - code: 'INVALID_SESSION_ID', - message: 'SDK session not found', - }) - }) - - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenCalledWith( - canonicalSessionId, - expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 5 }), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ) + expect(screen.getByRole('alert', { name: /permission request for bash/i })).toBeInTheDocument() + expect(screen.getByRole('region', { name: /question from claude/i })).toBeInTheDocument() }) - await waitFor(() => { - const renderedMessages = screen.getAllByRole('article') - .map((node) => node.textContent?.replace(/\s+/g, ' ').trim()) - expect(renderedMessages).toEqual([ - 'Recovered durable question', - 'Recovered durable answer', - ]) - }) + wsSend.mockClear() + fireEvent.click(screen.getByRole('button', { name: /allow tool use/i })) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) - await waitFor(() => { - expect(wsSend).toHaveBeenCalledWith(expect.objectContaining({ - type: 'sdk.create', - resumeSessionId: canonicalSessionId, - })) + expect(wsSend).toHaveBeenCalledWith({ + type: 'freshAgent.approval.respond', + sessionId: 'freshclaude-session-1', + sessionType: 'freshclaude', + provider: 'claude', + requestId: 'approval-1', + decision: { behavior: 'allow', updatedInput: {} }, }) - - const pane = findLeaf(store.getState().panes.layouts.t1!, 'p1') - expect(pane?.content.kind === 'agent-chat' ? pane.content.sessionRef : undefined).toEqual({ + expect(wsSend).toHaveBeenCalledWith({ + type: 'freshAgent.question.respond', + sessionId: 'freshclaude-session-1', + sessionType: 'freshclaude', provider: 'claude', - sessionId: canonicalSessionId, + requestId: 'question-1', + answers: { 'How should Claude proceed?': 'Continue' }, }) - expect(pane?.content.kind === 'agent-chat' ? pane.content.sessionId : undefined).toBeUndefined() }) }) diff --git a/test/e2e/agent-cli-flow.test.ts b/test/e2e/agent-cli-flow.test.ts index 65a4c528f..eea80af56 100644 --- a/test/e2e/agent-cli-flow.test.ts +++ b/test/e2e/agent-cli-flow.test.ts @@ -138,6 +138,15 @@ async function waitForExpect(assertions: () => void, timeoutMs = 2000, intervalM throw lastError ?? new Error('Timed out waiting for expectations to pass') } +function findPaneContent(node: any, paneId: string): any | undefined { + if (!node) return undefined + if (node.type === 'leaf') return node.id === paneId ? node.content : undefined + if (node.type === 'split') { + return findPaneContent(node.children?.[0], paneId) ?? findPaneContent(node.children?.[1], paneId) + } + return undefined +} + describe('cli e2e flow', () => { it('runs list-tabs end-to-end', async () => { const { url, close } = await startTestServer() @@ -427,6 +436,87 @@ describe('cli e2e flow', () => { } }) + it('passes canonical Codex session refs through new-tab, split-pane, and respawn-pane', async () => { + const server = await startTestServerWithRealLayoutStore() + try { + const created = await runCliJson<{ data: { tabId: string; paneId: string } }>(server.url, [ + 'new-tab', + '--mode', + 'codex', + '--session-ref', + 'codex:thread-cli-new', + ]) + const tabId = created.data.tabId + const firstPaneId = created.data.paneId + + await waitForExpect(() => { + const snapshot = (server.layoutStore as any).snapshot + expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ + mode: 'codex', + sessionRef: { provider: 'codex', sessionId: 'thread-cli-new' }, + })) + }) + + const split = await runCliJson<{ data: { paneId: string } }>(server.url, [ + 'split-pane', + '-t', + firstPaneId, + '--mode', + 'codex', + '--session-ref=codex:thread-cli-split', + ]) + + await runCliJson<{ data: { terminalId: string } }>(server.url, [ + 'respawn-pane', + '-t', + firstPaneId, + '--mode', + 'codex', + '--session-ref', + 'codex:thread-cli-respawn', + ]) + + await waitForExpect(() => { + const snapshot = (server.layoutStore as any).snapshot + expect(findPaneContent(snapshot.layouts[tabId], firstPaneId)).toEqual(expect.objectContaining({ + mode: 'codex', + sessionRef: { provider: 'codex', sessionId: 'thread-cli-respawn' }, + })) + expect(findPaneContent(snapshot.layouts[tabId], split.data.paneId)).toEqual(expect.objectContaining({ + mode: 'codex', + sessionRef: { provider: 'codex', sessionId: 'thread-cli-split' }, + })) + }) + } finally { + await server.close() + } + }) + + it('rejects raw Codex resume ids in new-tab, split-pane, and respawn-pane', async () => { + const server = await startTestServerWithRealLayoutStore() + try { + const created = await runCliJson<{ data: { paneId: string } }>(server.url, [ + 'new-tab', + '--mode', + 'codex', + ]) + + const commands = [ + ['new-tab', '--mode', 'codex', '--resume', 'thread-raw-new'], + ['split-pane', '-t', created.data.paneId, '--mode', 'codex', '--resume', 'thread-raw-split'], + ['respawn-pane', '-t', created.data.paneId, '--mode', 'codex', '--resume', 'thread-raw-respawn'], + ] + + for (const args of commands) { + const output = await runCliResult(server.url, args) + expect(output.code).toBe(1) + expect(output.stderr).toContain('Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.') + } + } finally { + await server.close() + } + }) + it('lists and resolves derived pane titles without an explicit rename', async () => { const server = await startTestServerWithRealLayoutStore() try { diff --git a/test/e2e/agent-cli-screenshot-smoke.test.ts b/test/e2e/agent-cli-screenshot-smoke.test.ts index 858d81587..851af8edb 100644 --- a/test/e2e/agent-cli-screenshot-smoke.test.ts +++ b/test/e2e/agent-cli-screenshot-smoke.test.ts @@ -45,7 +45,7 @@ function createFakeRegistry() { const input = vi.fn((terminalId: string, data: unknown) => { const record = records.get(terminalId) - if (!record || record.status !== 'running') return false + if (!record || record.status !== 'running') return { status: 'not_running' } const text = String(data ?? '') for (const ch of text) { @@ -61,7 +61,7 @@ function createFakeRegistry() { } record._pendingInput += ch } - return true + return { status: 'written' } }) const get = (terminalId: string) => records.get(terminalId) diff --git a/test/e2e/codex-refresh-rehydrate-flow.test.tsx b/test/e2e/codex-refresh-rehydrate-flow.test.tsx index 7585400ef..0a104ab6c 100644 --- a/test/e2e/codex-refresh-rehydrate-flow.test.tsx +++ b/test/e2e/codex-refresh-rehydrate-flow.test.tsx @@ -23,6 +23,7 @@ const wsHarness = vi.hoisted(() => { const reconnectHandlers = new Set<() => void>() const latestAttachRequestIdByTerminal = new Map<string, string>() const addedRestoreIds = new Set<string>() + const addedFreshRecoveryIds = new Map<string, string>() const withCurrentAttachRequestId = (msg: any) => { if ( @@ -64,15 +65,28 @@ const wsHarness = vi.hoisted(() => { addedRestoreIds.add(id) }, consumeRestoreRequestId(id: string) { + if (addedFreshRecoveryIds.has(id)) return false if (!addedRestoreIds.has(id)) return false addedRestoreIds.delete(id) return true }, + addFreshRecoveryRequestId(id: string, intent: string) { + addedRestoreIds.delete(id) + addedFreshRecoveryIds.set(id, intent) + }, + consumeFreshRecoveryRequest(id: string) { + const intent = addedFreshRecoveryIds.get(id) + if (!intent) return undefined + addedFreshRecoveryIds.delete(id) + addedRestoreIds.delete(id) + return intent + }, reset() { messageHandlers.clear() reconnectHandlers.clear() latestAttachRequestIdByTerminal.clear() addedRestoreIds.clear() + addedFreshRecoveryIds.clear() }, } }) @@ -89,6 +103,8 @@ vi.mock('@/lib/ws-client', () => ({ vi.mock('@/lib/terminal-restore', () => ({ addTerminalRestoreRequestId: (id: string) => wsHarness.addRestoreRequestId(id), consumeTerminalRestoreRequestId: (id: string) => wsHarness.consumeRestoreRequestId(id), + addTerminalFreshRecoveryRequestId: (id: string, intent: string) => wsHarness.addFreshRecoveryRequestId(id, intent), + consumeTerminalFreshRecoveryRequest: (id: string) => wsHarness.consumeFreshRecoveryRequest(id), })) vi.mock('@/lib/terminal-themes', () => ({ @@ -378,6 +394,168 @@ describe('codex refresh rehydrate flow (e2e)', () => { }) }) + it('recreates from captured Codex restore state after refresh when the live terminal id is gone', async () => { + const tabId = 'tab-codex-candidate-refresh' + const paneId = 'pane-codex-candidate-refresh' + const initialPaneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-codex-candidate-refresh', + status: 'creating', + mode: 'codex', + shell: 'system', + } + const candidateDurability = { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-candidate-refresh', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout-candidate-refresh.jsonl', + source: 'thread_start_response', + capturedAt: 1715720000000, + }, + } + + const initialStore = createStore({ + tabs: { + tabs: [{ + id: tabId, + mode: 'codex', + status: 'creating', + title: 'Codex', + titleSetByUser: false, + createRequestId: 'req-codex-candidate-refresh', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: { type: 'leaf', id: paneId, content: initialPaneContent } }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + }) + + const firstRender = render( + <Provider store={initialStore}> + <TerminalViewFromStore tabId={tabId} paneId={paneId} /> + </Provider>, + ) + + act(() => { + wsHarness.emit({ + type: 'terminal.created', + requestId: 'req-codex-candidate-refresh', + terminalId: 'term-codex-candidate-old', + createdAt: 1, + }) + wsHarness.emit({ + type: 'terminal.codex.durability.updated', + terminalId: 'term-codex-candidate-old', + durability: candidateDurability, + }) + }) + + await waitFor(() => { + expect(sentMessages()).toContainEqual({ + type: 'terminal.codex.candidate.persisted', + terminalId: 'term-codex-candidate-old', + candidateThreadId: 'thread-candidate-refresh', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout-candidate-refresh.jsonl', + capturedAt: 1715720000000, + }) + const persisted = readPersistedLayoutSnapshotForTest() + expect(persisted?.tabs.tabs.find((tab) => tab.id === tabId)?.sessionRef).toBeUndefined() + expect((persisted?.panes.layouts[tabId] as any)?.content?.sessionRef).toBeUndefined() + expect((persisted?.panes.layouts[tabId] as any)?.content?.codexDurability).toEqual(candidateDurability) + }) + + const persisted = readPersistedLayoutSnapshotForTest() + expect(persisted).toBeTruthy() + + firstRender.unmount() + cleanup() + wsHarness.reset() + wsHarness.send.mockClear() + wsHarness.send.mockImplementation((msg: any) => { + wsHarness.rememberAttach(msg) + }) + resetPersistedLayoutCacheForTests() + + const restoredStore = createStore({ + tabs: { + tabs: persisted!.tabs.tabs, + activeTabId: persisted!.tabs.activeTabId, + }, + panes: { + layouts: persisted!.panes.layouts, + activePane: persisted!.panes.activePane, + paneTitles: persisted!.panes.paneTitles, + paneTitleSetByUser: persisted!.panes.paneTitleSetByUser, + }, + }) + + render( + <Provider store={restoredStore}> + <TerminalViewFromStore tabId={tabId} paneId={paneId} /> + </Provider>, + ) + + act(() => { + wsHarness.emit({ + type: 'error', + code: 'INVALID_TERMINAL_ID', + message: 'Unknown terminalId', + terminalId: 'term-codex-candidate-old', + }) + }) + + await waitFor(() => { + const recreated = sentMessages().find((msg) => ( + msg?.type === 'terminal.create' + && msg?.requestId !== 'req-codex-candidate-refresh' + )) + expect(recreated).toMatchObject({ + type: 'terminal.create', + mode: 'codex', + codexDurability: candidateDurability, + restore: true, + }) + expect(recreated?.sessionRef).toBeUndefined() + expect(recreated?.resumeSessionId).toBeUndefined() + }) + + const recreated = sentMessages().find((msg) => ( + msg?.type === 'terminal.create' + && msg?.requestId !== 'req-codex-candidate-refresh' + )) + expect(recreated?.requestId).toBeTruthy() + + act(() => { + wsHarness.emit({ + type: 'terminal.created', + requestId: recreated!.requestId, + terminalId: 'term-codex-candidate-fresh', + createdAt: 2, + clearCodexDurability: true, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }) + }) + + await waitFor(() => { + const afterFreshCreate = readPersistedLayoutSnapshotForTest() + expect((afterFreshCreate?.panes.layouts[tabId] as any)?.content?.terminalId).toBe('term-codex-candidate-fresh') + expect((afterFreshCreate?.panes.layouts[tabId] as any)?.content?.codexDurability).toBeUndefined() + expect((afterFreshCreate?.panes.layouts[tabId] as any)?.content?.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + expect(afterFreshCreate?.tabs.tabs.find((tab) => tab.id === tabId)?.codexDurability).toBeUndefined() + }) + }) + it('reattaches a same-server live Codex terminal before any durable identity exists', async () => { const tabId = 'tab-codex-live' const paneId = 'pane-codex-live' @@ -429,7 +607,7 @@ describe('codex refresh rehydrate flow (e2e)', () => { expect(sentMessages().some((msg) => msg?.type === 'terminal.create')).toBe(false) }) - it('surfaces restore-unavailable instead of starting a fresh Codex session when a live-only terminal is gone', async () => { + it('asks the server to recover live-only Codex panes when the old terminal is gone', async () => { const tabId = 'tab-codex-live-only' const paneId = 'pane-codex-live-only' const store = createStore({ @@ -489,11 +667,15 @@ describe('codex refresh rehydrate flow (e2e)', () => { }) await waitFor(() => { - expect(sentMessages().slice(baselineMessages).some((msg) => msg?.type === 'terminal.create')).toBe(false) - expect((getTerminalPaneContent(store, tabId) as any)?.restoreError).toEqual({ - code: 'RESTORE_UNAVAILABLE', - reason: 'dead_live_handle', + const recreated = sentMessages().slice(baselineMessages).find((msg) => msg?.type === 'terminal.create') + expect(recreated).toMatchObject({ + type: 'terminal.create', + mode: 'codex', + recoveryIntent: 'fresh_after_restore_unavailable', }) + expect(recreated?.restore).toBeUndefined() + expect(recreated?.sessionRef).toBeUndefined() + expect(recreated?.codexDurability).toBeUndefined() }) }) }) diff --git a/test/e2e/codex-session-resilience-flow.test.tsx b/test/e2e/codex-session-resilience-flow.test.tsx index 6e3b4f20f..aadc221d7 100644 --- a/test/e2e/codex-session-resilience-flow.test.tsx +++ b/test/e2e/codex-session-resilience-flow.test.tsx @@ -40,6 +40,8 @@ vi.mock('@/lib/terminal-themes', () => ({ vi.mock('@/lib/terminal-restore', () => ({ consumeTerminalRestoreRequestId: vi.fn(() => false), addTerminalRestoreRequestId: vi.fn(), + consumeTerminalFreshRecoveryRequest: vi.fn(() => undefined), + addTerminalFreshRecoveryRequestId: vi.fn(), })) vi.mock('lucide-react', () => ({ diff --git a/test/e2e/opencode-scroll-input-policy.test.tsx b/test/e2e/opencode-scroll-input-policy.test.tsx index 42cd883d0..fe3c23dc5 100644 --- a/test/e2e/opencode-scroll-input-policy.test.tsx +++ b/test/e2e/opencode-scroll-input-policy.test.tsx @@ -103,7 +103,7 @@ const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { cli: { terminalBehavior: { preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }, }, } @@ -178,7 +178,7 @@ describe('opencode scroll input policy (e2e)', () => { vi.unstubAllGlobals() }) - it('sends cursor-key input when an OpenCode pane receives wheel input in alt screen mouse mode', async () => { + it('does not translate wheel input for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) const { getByTestId } = render( @@ -195,10 +195,8 @@ describe('opencode scroll input policy (e2e)', () => { fireEvent.wheel(getByTestId('terminal-xterm-container'), { deltaY: 24 }) - expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', })) }) diff --git a/test/e2e/opencode-touch-scroll-input-policy.test.tsx b/test/e2e/opencode-touch-scroll-input-policy.test.tsx index 5dab864ca..6f041d09a 100644 --- a/test/e2e/opencode-touch-scroll-input-policy.test.tsx +++ b/test/e2e/opencode-touch-scroll-input-policy.test.tsx @@ -101,7 +101,7 @@ const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { cli: { terminalBehavior: { preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }, }, } @@ -177,7 +177,7 @@ describe('opencode touch scroll input policy (e2e)', () => { ;(globalThis as any).setMobileForTest(false) }) - it('sends translated cursor-key input instead of local scrollback for OpenCode touch scrolling', async () => { + it('does not translate touch scroll for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) const { getByTestId } = render( @@ -202,14 +202,12 @@ describe('opencode touch scroll input policy (e2e)', () => { }) expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() - expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', })) }) - it('keeps native touch scrolling for non-opted-in providers', async () => { + it('skips scrollLines in alt screen for non-opted-in providers', async () => { const { store, tabId, paneId, paneContent } = createStore('shell') const { getByTestId } = render( @@ -233,7 +231,7 @@ describe('opencode touch scroll input policy (e2e)', () => { touches: [{ clientX: 20, clientY: 100 }], }) - expect(latestTerminal?.scrollLines).toHaveBeenCalledWith(1) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', })) diff --git a/test/e2e/settings-devices-flow.test.tsx b/test/e2e/settings-devices-flow.test.tsx index c6cfca538..f4a4ef043 100644 --- a/test/e2e/settings-devices-flow.test.tsx +++ b/test/e2e/settings-devices-flow.test.tsx @@ -52,9 +52,12 @@ function createTabRegistryState(overrides: Partial<TabRegistryState> = {}): TabR deviceId: 'local-device', deviceLabel: 'local-device', localOpen: [], + sameDeviceOpen: [], remoteOpen: [], + devices: [], closed: [], localClosed: {}, + closedTabRetentionDays: 30, loading: false, searchRangeDays: 30, ...overrides, @@ -106,11 +109,15 @@ describe('settings devices management flow (e2e)', () => { vi.useRealTimers() }) - it('collapses duplicate machine rows, deletes a remote device, and renders Devices last', async () => { + it('renders server-backed device rows, deletes one remote device, and renders Devices last', async () => { const store = createStore({ remoteOpen: [ makeRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), ], + devices: [ + { deviceId: 'remote-a', deviceLabel: 'studio-mac', lastSeenAt: 10 }, + { deviceId: 'remote-b', deviceLabel: 'studio-mac', lastSeenAt: 5 }, + ], closed: [ makeRecord({ deviceId: 'remote-b', @@ -131,19 +138,19 @@ describe('settings devices management flow (e2e)', () => { ) fireEvent.click(screen.getByRole('tab', { name: /^safety$/i })) - expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) + expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(2) const devicesHeading = screen.getByText('Devices') const networkHeading = screen.getByText('Network Access') expect(devicesHeading.compareDocumentPosition(networkHeading) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Delete device studio-mac' })) + fireEvent.click(screen.getAllByRole('button', { name: 'Delete device studio-mac' })[0]) await act(async () => { await Promise.resolve() }) - expect(screen.queryByLabelText('Device name for studio-mac')).not.toBeInTheDocument() - expect(JSON.parse(localStorage.getItem(DEVICE_DISMISSED_STORAGE_KEY) || '[]').sort()).toEqual(['remote-a', 'remote-b']) + expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) + expect(JSON.parse(localStorage.getItem(DEVICE_DISMISSED_STORAGE_KEY) || '[]')).toEqual(['remote-a']) }) }) diff --git a/test/e2e/sidebar-busy-icon-flow.test.tsx b/test/e2e/sidebar-busy-icon-flow.test.tsx index 1eb103f29..1f5b58395 100644 --- a/test/e2e/sidebar-busy-icon-flow.test.tsx +++ b/test/e2e/sidebar-busy-icon-flow.test.tsx @@ -78,7 +78,7 @@ const defaultCliExtensions: ClientExtensionEntry[] = [ const sessionId = (label: string) => { const hex = createHash('md5').update(label).digest('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}` } function createStore(options: { diff --git a/test/e2e/sidebar-click-opens-pane.test.tsx b/test/e2e/sidebar-click-opens-pane.test.tsx index b246f31da..abf8dc8f7 100644 --- a/test/e2e/sidebar-click-opens-pane.test.tsx +++ b/test/e2e/sidebar-click-opens-pane.test.tsx @@ -62,7 +62,7 @@ vi.mock('@/lib/api', async () => { const sessionId = (label: string) => { const chars = Array.from(label).map((ch, idx) => ((ch.charCodeAt(0) + idx) % 16).toString(16)) const hex = chars.join('').padEnd(32, '0').slice(0, 32) - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}` } function createStore(options: { diff --git a/test/e2e/tabs-view-flow.test.tsx b/test/e2e/tabs-view-flow.test.tsx index a51ade9f4..6b34b13f4 100644 --- a/test/e2e/tabs-view-flow.test.tsx +++ b/test/e2e/tabs-view-flow.test.tsx @@ -153,6 +153,77 @@ describe('tabs view flow', () => { expect(copiedLayout?.content?.terminalId).toBeUndefined() }) + it('preserves candidate-only Codex durability state when pulling a registry tab', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) + store.dispatch(setServerInstanceId('srv-local')) + const codexDurability = { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: '019e2413-b8d0-7a98-b5fb-2f4af05baf58', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + source: 'thread_start_response', + capturedAt: 1778764200000, + }, + } as const + + store.dispatch(setTabRegistrySnapshot({ + localOpen: [], + remoteOpen: [{ + tabKey: 'remote:tab-codex-candidate', + tabId: 'tab-codex-candidate', + serverInstanceId: 'srv-remote', + deviceId: 'remote', + deviceLabel: 'remote-device', + tabName: 'codex candidate', + status: 'open', + revision: 2, + createdAt: 10, + updatedAt: 20, + paneCount: 1, + titleSetByUser: false, + panes: [{ + paneId: 'pane-codex-candidate', + kind: 'terminal', + payload: { + mode: 'codex', + codexDurability, + liveTerminal: { + terminalId: 'term-remote-candidate', + serverInstanceId: 'srv-remote', + }, + }, + }], + }], + closed: [], + })) + + render( + <Provider store={store}> + <TabsView /> + </Provider>, + ) + + const remoteCard = screen.getByLabelText('remote-device: codex candidate') + expect(remoteCard).toBeTruthy() + fireEvent.click(remoteCard) + + const copiedTab = store.getState().tabs.tabs[0] + expect(copiedTab?.title).toBe('codex candidate') + const copiedLayout = copiedTab ? (store.getState().panes.layouts[copiedTab.id] as any) : undefined + expect(copiedLayout?.content?.sessionRef).toBeUndefined() + expect(copiedLayout?.content?.codexDurability).toEqual(codexDurability) + expect(copiedLayout?.content?.terminalId).toBeUndefined() + }) + it('opens same-server tab copies with an explicit live terminal handle', () => { const store = configureStore({ reducer: { diff --git a/test/e2e/tabs-view-search-range.test.tsx b/test/e2e/tabs-view-search-range.test.tsx index ea23d1830..454fe7099 100644 --- a/test/e2e/tabs-view-search-range.test.tsx +++ b/test/e2e/tabs-view-search-range.test.tsx @@ -34,7 +34,8 @@ describe('tabs view search range loading', () => { cleanup() }) - it('requests older history only when user expands search range', () => { + it('updates the registered retention range without issuing an untracked direct query', () => { + const initialTabRegistry = tabRegistryReducer(undefined, { type: '@@INIT' }) const store = configureStore({ reducer: { tabs: tabsReducer, @@ -42,6 +43,13 @@ describe('tabs view search range loading', () => { tabRegistry: tabRegistryReducer, connection: connectionReducer, }, + preloadedState: { + tabRegistry: { + ...initialTabRegistry, + closedTabRetentionDays: 1, + searchRangeDays: 1, + }, + }, }) render( @@ -53,10 +61,10 @@ describe('tabs view search range loading', () => { expect(wsMock.sendTabsSyncQuery).not.toHaveBeenCalled() fireEvent.change(screen.getByLabelText('Closed range filter'), { - target: { value: '90' }, + target: { value: '30' }, }) - expect(wsMock.sendTabsSyncQuery).toHaveBeenCalledTimes(1) - expect(wsMock.sendTabsSyncQuery.mock.calls[0][0].rangeDays).toBe(90) + expect(wsMock.sendTabsSyncQuery).not.toHaveBeenCalled() + expect(store.getState().tabRegistry.closedTabRetentionDays).toBe(30) }) it('hydrates the closed range filter from browser preferences on reload', async () => { @@ -83,6 +91,6 @@ describe('tabs view search range loading', () => { </Provider>, ) - expect(screen.getByLabelText('Closed range filter')).toHaveValue('90') + expect(screen.getByLabelText('Closed range filter')).toHaveValue('30') }) }) diff --git a/test/e2e/terminal-restart-recovery.test.tsx b/test/e2e/terminal-restart-recovery.test.tsx new file mode 100644 index 000000000..154de5d6e --- /dev/null +++ b/test/e2e/terminal-restart-recovery.test.tsx @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import tabsReducer from '@/store/tabsSlice' +import panesReducer, { clearDeadTerminals } from '@/store/panesSlice' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import connectionReducer from '@/store/connectionSlice' +import { useAppSelector } from '@/store/hooks' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import TerminalView from '@/components/TerminalView' + +const wsHarness = vi.hoisted(() => { + const messageHandlers = new Set<(msg: any) => void>() + const addedRestoreIds = new Set<string>() + const addedFreshRecoveryIds = new Map<string, string>() + + return { + send: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn((handler: (msg: any) => void) => { + messageHandlers.add(handler) + return () => messageHandlers.delete(handler) + }), + onReconnect: vi.fn(() => () => {}), + addRestoreRequestId(id: string) { + addedRestoreIds.add(id) + }, + consumeRestoreRequestId(id: string) { + if (!addedRestoreIds.has(id)) return false + addedRestoreIds.delete(id) + return true + }, + addFreshRecoveryRequestId(id: string, intent: string) { + addedFreshRecoveryIds.set(id, intent) + addedRestoreIds.delete(id) + }, + consumeFreshRecoveryRequest(id: string) { + const intent = addedFreshRecoveryIds.get(id) + if (!intent) return undefined + addedFreshRecoveryIds.delete(id) + addedRestoreIds.delete(id) + return intent + }, + reset() { + messageHandlers.clear() + addedRestoreIds.clear() + addedFreshRecoveryIds.clear() + }, + } +}) + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: wsHarness.send, + connect: wsHarness.connect, + onMessage: wsHarness.onMessage, + onReconnect: wsHarness.onReconnect, + }), +})) + +vi.mock('@/lib/terminal-restore', () => ({ + addTerminalRestoreRequestId: (id: string) => wsHarness.addRestoreRequestId(id), + consumeTerminalRestoreRequestId: (id: string) => wsHarness.consumeRestoreRequestId(id), + addTerminalFreshRecoveryRequestId: (id: string, intent: string) => wsHarness.addFreshRecoveryRequestId(id, intent), + consumeTerminalFreshRecoveryRequest: (id: string) => wsHarness.consumeFreshRecoveryRequest(id), +})) + +vi.mock('@/lib/terminal-themes', () => ({ + getTerminalTheme: () => ({}), +})) + +vi.mock('@/components/terminal/terminal-runtime', () => ({ + createTerminalRuntime: () => ({ + attachAddons: vi.fn(), + fit: vi.fn(), + findNext: vi.fn(() => false), + findPrevious: vi.fn(() => false), + clearDecorations: vi.fn(), + onDidChangeResults: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + webglActive: vi.fn(() => false), + }), +})) + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + options: Record<string, unknown> = {} + cols = 80 + rows = 24 + open = vi.fn() + registerLinkProvider = vi.fn(() => ({ dispose: vi.fn() })) + onData = vi.fn(() => ({ dispose: vi.fn() })) + onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) + attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() + dispose = vi.fn() + focus = vi.fn() + getSelection = vi.fn(() => '') + clear = vi.fn() + write = vi.fn((data: string, cb?: () => void) => { + cb?.() + return data.length + }) + writeln = vi.fn() + } + + return { Terminal: MockTerminal } +}) + +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +function findLeaf(node: PaneNode | undefined, paneId: string): Extract<PaneNode, { type: 'leaf' }> | null { + if (!node) return null + if (node.type === 'leaf') return node.id === paneId ? node : null + return findLeaf(node.children[0], paneId) || findLeaf(node.children[1], paneId) +} + +function TerminalViewFromStore({ tabId, paneId }: { tabId: string; paneId: string }) { + const paneContent = useAppSelector((state) => findLeaf(state.panes.layouts[tabId], paneId)?.content ?? null) + if (!paneContent || paneContent.kind !== 'terminal') return null + return <TerminalView tabId={tabId} paneId={paneId} paneContent={paneContent} /> +} + +function createStore(layout: PaneNode) { + return configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: 'tab-restart', + mode: 'codex', + status: 'running', + title: 'Restart', + titleSetByUser: false, + createRequestId: 'tab-restart', + }], + activeTabId: 'tab-restart', + }, + panes: { + layouts: { 'tab-restart': layout }, + activePane: { 'tab-restart': 'pane-codex' }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' }, + connection: { status: 'ready', error: null, serverInstanceId: 'srv-new' }, + }, + }) +} + +function registerRecoveryRequestsFromState(store: ReturnType<typeof createStore>) { + const state = store.getState() + const attempts = state.panes.restoreFallbackAttemptsByPane || {} + const walk = (tabId: string, node: PaneNode) => { + if (node.type === 'leaf') { + const content = node.content + if (content.kind !== 'terminal' || content.status !== 'creating') return + const attempt = attempts[tabId]?.[node.id] + if (attempt?.requestId === content.createRequestId && !content.sessionRef) { + wsHarness.addFreshRecoveryRequestId(content.createRequestId, 'fresh_after_restore_unavailable') + } else if (content.sessionRef) { + wsHarness.addRestoreRequestId(content.createRequestId) + } + return + } + walk(tabId, node.children[0]) + walk(tabId, node.children[1]) + } + + for (const [tabId, node] of Object.entries(state.panes.layouts)) { + walk(tabId, node) + } +} + +function sentMessages() { + return wsHarness.send.mock.calls.map(([msg]) => msg) +} + +describe('terminal restart recovery (e2e)', () => { + beforeEach(() => { + wsHarness.reset() + wsHarness.send.mockClear() + vi.stubGlobal('ResizeObserver', MockResizeObserver) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + cb(0) + return 1 + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('restores panes with durable identity and fresh-recovers missing-identity panes once after inventory loss', async () => { + const layout: PaneNode = { + type: 'split', + id: 'split-root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-codex', + content: { + kind: 'terminal', + createRequestId: 'req-codex-old', + status: 'running', + mode: 'codex', + shell: 'system', + terminalId: 'term-codex-old', + serverInstanceId: 'srv-old', + sessionRef: { + provider: 'codex', + sessionId: 'codex-session-1', + }, + } satisfies TerminalPaneContent, + }, + { + type: 'leaf', + id: 'pane-shell', + content: { + kind: 'terminal', + createRequestId: 'req-shell-old', + status: 'running', + mode: 'shell', + shell: 'system', + terminalId: 'term-shell-old', + serverInstanceId: 'srv-old', + } satisfies TerminalPaneContent, + }, + ], + } + + const store = createStore(layout) + + render( + <Provider store={store}> + <TerminalViewFromStore tabId="tab-restart" paneId="pane-codex" /> + <TerminalViewFromStore tabId="tab-restart" paneId="pane-shell" /> + </Provider>, + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-codex-old')).toBe(true) + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-shell-old')).toBe(true) + }) + + wsHarness.send.mockClear() + store.dispatch(clearDeadTerminals({ liveTerminalIds: [] })) + registerRecoveryRequestsFromState(store) + + await waitFor(() => { + const creates = sentMessages().filter((msg) => msg?.type === 'terminal.create') + expect(creates).toHaveLength(2) + + const codexCreate = creates.find((msg) => msg.mode === 'codex') + expect(codexCreate).toMatchObject({ + type: 'terminal.create', + mode: 'codex', + restore: true, + sessionRef: { + provider: 'codex', + sessionId: 'codex-session-1', + }, + }) + + const shellCreate = creates.find((msg) => msg.mode === 'shell') + expect(shellCreate).toMatchObject({ + type: 'terminal.create', + mode: 'shell', + recoveryIntent: 'fresh_after_restore_unavailable', + }) + expect(shellCreate).not.toHaveProperty('restore') + expect(shellCreate).not.toHaveProperty('sessionRef') + expect(shellCreate).not.toHaveProperty('liveTerminal') + }) + + wsHarness.send.mockClear() + store.dispatch(clearDeadTerminals({ liveTerminalIds: [] })) + registerRecoveryRequestsFromState(store) + + await waitFor(() => { + expect(sentMessages().filter((msg) => msg?.type === 'terminal.create')).toHaveLength(0) + }) + }) +}) diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index 0c741f60b..ca37bea4d 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -3,6 +3,7 @@ import { WebSocketServer } from 'ws' import { spawn } from 'node:child_process' import fs from 'node:fs' +import path from 'node:path' if (process.argv[2] === 'fake-native-child') { process.on('SIGTERM', () => { @@ -31,6 +32,42 @@ function loadBehavior() { return JSON.parse(raw) } +function getCodexHome() { + return process.env.CODEX_HOME || '/tmp/fake-codex-home' +} + +function getRolloutSessionDir() { + const now = new Date() + const year = String(now.getUTCFullYear()) + const month = String(now.getUTCMonth() + 1).padStart(2, '0') + const day = String(now.getUTCDate()).padStart(2, '0') + return path.join(getCodexHome(), 'sessions', year, month, day) +} + +function getThreadHandle(threadId) { + return { + id: threadId, + path: path.join(getRolloutSessionDir(), `rollout-${threadId}.jsonl`), + ephemeral: false, + } +} + +function ensureDurableArtifact(threadId) { + const thread = getThreadHandle(threadId) + const codexHome = getCodexHome() + const now = new Date() + const sessionDir = path.dirname(thread.path) + fs.mkdirSync(sessionDir, { recursive: true }) + fs.writeFileSync(thread.path, JSON.stringify({ + threadId, + createdAt: now.toISOString(), + }) + '\n', 'utf8') + return { + codexHome, + thread, + } +} + function writeBytes(stream, totalBytes, chunkSize = 16 * 1024) { if (!Number.isFinite(totalBytes) || totalBytes <= 0) { return Promise.resolve() @@ -59,45 +96,109 @@ function writeBytes(stream, totalBytes, chunkSize = 16 * 1024) { }) } +function makeTurn(id = 'turn-1') { + return { + id, + status: 'completed', + items: [{ + type: 'agentMessage', + id: `${id}:item-0`, + text: 'Fixture turn', + phase: null, + memoryCitation: null, + }], + error: null, + startedAt: 1770000001, + completedAt: 1770000002, + durationMs: 1000, + } +} + +function makeThread(id, params = {}) { + const handle = getThreadHandle(id) + return { + id, + sessionId: id, + preview: 'Fixture turn', + ephemeral: false, + modelProvider: 'openai', + createdAt: 1770000000, + updatedAt: 1770000007, + status: { type: 'idle' }, + cwd: params.cwd ?? process.cwd(), + cliVersion: 'codex-cli 0.129.0', + source: 'appServer', + turns: params.includeTurns ? [makeTurn()] : [], + path: handle.path, + } +} + function successResult(method, params) { if (method === 'initialize') { return { userAgent: 'freshell-fixture/1.0.0', - codexHome: '/tmp/fake-codex-home', + codexHome: getCodexHome(), platformFamily: 'unix', platformOs: 'linux', } } if (method === 'thread/start') { + const threadId = behavior.threadStartThreadId || 'thread-new-1' + const rolloutPath = behavior.threadStartRolloutPath || behavior.rolloutPath + const thread = makeThread(threadId, params) + if (rolloutPath) thread.path = rolloutPath + if (typeof behavior.threadStartEphemeral === 'boolean') { + thread.ephemeral = behavior.threadStartEphemeral + } return { - thread: { - id: 'thread-new-1', - }, + thread, cwd: params?.cwd ?? process.cwd(), model: 'fixture-model', modelProvider: 'openai', instructionSources: [], approvalPolicy: 'never', approvalsReviewer: 'user', - sandbox: { - type: 'dangerFullAccess', - }, + sandbox: params?.sandbox ?? 'danger-full-access', } } if (method === 'thread/resume') { + const threadId = params?.threadId || 'thread-new-1' + const rolloutPath = behavior.threadResumeRolloutPath || behavior.rolloutPath + const thread = makeThread(threadId, params) + if (rolloutPath) thread.path = rolloutPath + if (typeof behavior.threadResumeEphemeral === 'boolean') { + thread.ephemeral = behavior.threadResumeEphemeral + } return { - thread: { - id: params?.threadId, - }, + thread, cwd: params?.cwd ?? process.cwd(), model: 'fixture-model', modelProvider: 'openai', instructionSources: [], approvalPolicy: 'never', approvalsReviewer: 'user', - sandbox: { - type: 'dangerFullAccess', - }, + sandbox: params?.sandbox ?? 'danger-full-access', + } + } + if (method === 'turn/start') { + return { + turn: makeTurn('turn-1'), + } + } + if (method === 'fs/watch') { + return { + path: path.resolve(String(params?.path || '')), + } + } + if (method === 'fs/unwatch') { + return {} + } + if (method === 'thread/read') { + return { + thread: makeThread(params?.threadId, { + ...params, + includeTurns: params?.includeTurns === true, + }), } } if (method === 'thread/loaded/list') { @@ -108,9 +209,40 @@ function successResult(method, params) { return {} } +function maybeWriteRolloutForMethod(method, params) { + const spec = behavior.writeRolloutOnMethods?.[method] + if (!spec?.path) return + const threadId = spec.threadId || params?.threadId || behavior.threadStartThreadId || 'thread-new-1' + fs.mkdirSync(path.dirname(spec.path), { recursive: true }) + const line = JSON.stringify(spec.record || { + type: 'session_meta', + payload: { id: threadId }, + }) + '\n' + if (spec.append) { + fs.appendFileSync(spec.path, line, 'utf8') + } else { + fs.writeFileSync(spec.path, line, 'utf8') + } +} + const listenUrl = parseListenUrl(process.argv.slice(2)) const behavior = loadBehavior() +if (process.env.FAKE_CODEX_APP_SERVER_ARG_LOG) { + fs.writeFileSync(process.env.FAKE_CODEX_APP_SERVER_ARG_LOG, JSON.stringify({ + argv: process.argv.slice(2), + env: { + FRESHELL: process.env.FRESHELL, + FRESHELL_URL: process.env.FRESHELL_URL, + FRESHELL_TOKEN: process.env.FRESHELL_TOKEN, + FRESHELL_TERMINAL_ID: process.env.FRESHELL_TERMINAL_ID, + FRESHELL_TAB_ID: process.env.FRESHELL_TAB_ID, + FRESHELL_PANE_ID: process.env.FRESHELL_PANE_ID, + }, + }), 'utf8') +} const closeSocketAfterMethodsOnce = new Set(behavior.closeSocketAfterMethodsOnce || []) +const exitProcessAfterMethodsOnce = new Set(behavior.exitProcessAfterMethodsOnce || []) +const threadClosedAfterMethodsOnce = new Set(behavior.threadClosedAfterMethodsOnce || []) const url = new URL(listenUrl) const host = url.hostname const port = Number(url.port) @@ -134,14 +266,114 @@ if (behavior.spawnNativeChild) { } const wss = new WebSocketServer({ host, port }) +const watches = new Map() +const activeThreadIds = new Set() + +function broadcastNotification(method, params) { + const payload = JSON.stringify({ + jsonrpc: '2.0', + method, + params, + }) + for (const client of wss.clients) { + if (client.readyState === 1) { + client.send(payload) + } + } +} + +function emitConfiguredNotifications(method) { + const onceNotifications = behavior.notifyAfterMethodsOnce?.[method] + if (Array.isArray(onceNotifications) && onceNotifications.length > 0) { + delete behavior.notifyAfterMethodsOnce[method] + for (const notification of onceNotifications) { + broadcastNotification(notification.method, notification.params) + } + } + + for (const notification of behavior.notificationsAfterMethods?.[method] || []) { + socketSafeBroadcast(notification) + } +} + +function socketSafeBroadcast(notification) { + if (notification?.method) { + broadcastNotification(notification.method, notification.params) + return + } + const payload = JSON.stringify(notification) + for (const client of wss.clients) { + if (client.readyState === 1) { + client.send(payload) + } + } +} + +function appendThreadOperation(method, params, result) { + const logPath = behavior.appendThreadOperationLogPath + if (!logPath || !method.startsWith('thread/')) { + return + } + const threadId = result?.thread?.id || params?.threadId || null + fs.mkdirSync(path.dirname(logPath), { recursive: true }) + fs.appendFileSync(logPath, JSON.stringify({ + method, + threadId, + params, + listenUrl, + at: new Date().toISOString(), + }) + '\n', 'utf8') +} + +function claimCrossProcessOnce(markerPath, key) { + if (!markerPath) { + return true + } + + try { + fs.mkdirSync(path.dirname(markerPath), { recursive: true }) + fs.writeFileSync(markerPath, `${key}\n`, { flag: 'wx' }) + return true + } catch (error) { + if (error && error.code === 'EEXIST') { + return false + } + throw error + } +} + +function emitConfiguredThreadStatusChanges(method) { + const byMethod = behavior.threadStatusChangedAfterMethodsOnce + const entries = byMethod?.[method] + if (!Array.isArray(entries) || entries.length === 0) { + return + } + if (!claimCrossProcessOnce(behavior.threadStatusChangedAfterMethodsOnceMarkerPath, `thread-status:${method}`)) { + return + } + delete byMethod[method] + for (const entry of entries) { + broadcastNotification('thread/status/changed', { + threadId: entry.threadId, + status: entry.status, + }) + } +} + +function claimCrossProcessCloseSocketOnce(method) { + return claimCrossProcessOnce(behavior.closeSocketAfterMethodsOnceMarkerPath, `close-socket:${method}`) +} wss.on('connection', (socket) => { let initialized = false let initializedNotification = false socket.on('message', (raw) => { const message = JSON.parse(raw.toString()) - if (message.method === 'initialized') { - initializedNotification = true + if (!Object.prototype.hasOwnProperty.call(message, 'id')) { + if (message.method === 'initialized') { + initializedNotification = true + initialized = true + } return } if (behavior.requireJsonRpc && message.jsonrpc !== '2.0') { @@ -154,6 +386,16 @@ wss.on('connection', (socket) => { })) return } + if (behavior.rejectJsonRpc && Object.prototype.hasOwnProperty.call(message, 'jsonrpc')) { + socket.send(JSON.stringify({ + id: message.id, + error: { + code: -32600, + message: 'Expected Codex app-server request envelope without jsonrpc', + }, + })) + return + } const method = message.method if ( @@ -175,6 +417,17 @@ wss.on('connection', (socket) => { return } + if (behavior.assertNoDuplicateActiveThread && method === 'thread/start' && activeThreadIds.size > 0) { + socket.send(JSON.stringify({ + id: message.id, + error: { + code: -32001, + message: `Duplicate active thread start attempted while ${[...activeThreadIds].join(', ')} is active`, + }, + })) + return + } + const override = behavior.overrides?.[method] const delayMs = Number(behavior.delayMethodsMs?.[method] || 0) const floodStdoutBytes = Number(behavior.floodStdoutBeforeMethodsBytes?.[method] || 0) @@ -192,19 +445,81 @@ wss.on('connection', (socket) => { setTimeout(async () => { await writeBytes(process.stdout, floodStdoutBytes) await writeBytes(process.stderr, floodStderrBytes) + const result = override?.result ?? successResult(method, message.params) socket.send(JSON.stringify({ id: message.id, - result: override?.result ?? successResult(method, message.params), + result, })) - for (const notification of behavior.notificationsAfterMethods?.[method] || []) { - socket.send(JSON.stringify(notification)) - } + appendThreadOperation(method, message.params, result) + maybeWriteRolloutForMethod(method, message.params) if (method === 'initialize') { initialized = true } - if (closeSocketAfterMethodsOnce.delete(method)) { + if (method === 'thread/start') { + const thread = result?.thread || getThreadHandle(message.params?.threadId || 'thread-new-1') + activeThreadIds.add(thread.id) + broadcastNotification('thread/started', { + thread, + }) + } + if (method === 'thread/resume') { + const thread = result?.thread || getThreadHandle(message.params?.threadId || 'thread-new-1') + activeThreadIds.add(thread.id) + broadcastNotification('thread/started', { + thread, + }) + } + if (method === 'fs/watch') { + const watchId = message.params?.watchId + const watchedPath = result?.path + if (watchId && watchedPath) { + watches.set(watchId, watchedPath) + } + } + if (method === 'fs/unwatch') { + const watchId = message.params?.watchId + if (watchId) { + watches.delete(watchId) + } + } + if (method === 'turn/start' && message.params?.threadId) { + const { thread } = ensureDurableArtifact(message.params.threadId) + const rolloutPath = thread.path + const rolloutParent = path.dirname(rolloutPath) + for (const [watchId, watchedPath] of watches) { + if (watchedPath !== rolloutPath && watchedPath !== rolloutParent) { + continue + } + broadcastNotification('fs/changed', { + watchId, + changedPaths: [rolloutPath], + }) + } + } + emitConfiguredNotifications(method) + if ( + threadClosedAfterMethodsOnce.delete(method) + && claimCrossProcessOnce(behavior.threadClosedAfterMethodsOnceMarkerPath, `thread-closed:${method}`) + ) { + const threadId = result?.thread?.id || message.params?.threadId || 'thread-new-1' + activeThreadIds.delete(threadId) + broadcastNotification('thread/closed', { threadId }) + } + emitConfiguredThreadStatusChanges(method) + if (closeSocketAfterMethodsOnce.delete(method) && claimCrossProcessCloseSocketOnce(method)) { setTimeout(() => socket.close(), 0) } + if (exitProcessAfterMethodsOnce.delete(method)) { + setTimeout(() => { + if (behavior.stdoutBeforeExit) { + process.stdout.write(String(behavior.stdoutBeforeExit)) + } + if (behavior.stderrBeforeExit) { + process.stderr.write(String(behavior.stderrBeforeExit)) + } + process.exit(0) + }, 0) + } }, delayMs) }) }) diff --git a/test/fixtures/coding-cli/codex-app-server/schema-inventory.ts b/test/fixtures/coding-cli/codex-app-server/schema-inventory.ts new file mode 100644 index 000000000..268a41841 --- /dev/null +++ b/test/fixtures/coding-cli/codex-app-server/schema-inventory.ts @@ -0,0 +1,196 @@ +export const CODEX_SCHEMA_VERSION = '0.129.0' as const + +export const CODEX_CLIENT_REQUEST_METHODS = [ + 'initialize', + 'thread/start', + 'thread/resume', + 'thread/fork', + 'thread/archive', + 'thread/unsubscribe', + 'thread/name/set', + 'thread/metadata/update', + 'thread/unarchive', + 'thread/compact/start', + 'thread/shellCommand', + 'thread/approveGuardianDeniedAction', + 'thread/rollback', + 'thread/list', + 'thread/loaded/list', + 'thread/read', + 'thread/inject_items', + 'skills/list', + 'hooks/list', + 'marketplace/add', + 'marketplace/remove', + 'marketplace/upgrade', + 'plugin/list', + 'plugin/read', + 'plugin/skill/read', + 'plugin/share/save', + 'plugin/share/updateTargets', + 'plugin/share/list', + 'plugin/share/delete', + 'app/list', + 'device/key/create', + 'device/key/public', + 'device/key/sign', + 'fs/readFile', + 'fs/writeFile', + 'fs/createDirectory', + 'fs/getMetadata', + 'fs/readDirectory', + 'fs/remove', + 'fs/copy', + 'fs/watch', + 'fs/unwatch', + 'skills/config/write', + 'plugin/install', + 'plugin/uninstall', + 'turn/start', + 'turn/steer', + 'turn/interrupt', + 'review/start', + 'model/list', + 'modelProvider/capabilities/read', + 'experimentalFeature/list', + 'experimentalFeature/enablement/set', + 'mcpServer/oauth/login', + 'config/mcpServer/reload', + 'mcpServerStatus/list', + 'mcpServer/resource/read', + 'mcpServer/tool/call', + 'windowsSandbox/setupStart', + 'windowsSandbox/readiness', + 'account/login/start', + 'account/login/cancel', + 'account/logout', + 'account/rateLimits/read', + 'account/sendAddCreditsNudgeEmail', + 'feedback/upload', + 'command/exec', + 'command/exec/write', + 'command/exec/terminate', + 'command/exec/resize', + 'config/read', + 'externalAgentConfig/detect', + 'externalAgentConfig/import', + 'config/value/write', + 'config/batchWrite', + 'configRequirements/read', + 'account/read', + 'fuzzyFileSearch', +] as const + +export const CODEX_SERVER_REQUEST_METHODS = [ + 'item/commandExecution/requestApproval', + 'item/fileChange/requestApproval', + 'item/tool/requestUserInput', + 'mcpServer/elicitation/request', + 'item/permissions/requestApproval', + 'item/tool/call', + 'account/chatgptAuthTokens/refresh', + 'applyPatchApproval', + 'execCommandApproval', +] as const + +export const CODEX_SERVER_NOTIFICATION_METHODS = [ + 'error', + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/unarchived', + 'thread/closed', + 'skills/changed', + 'thread/name/updated', + 'thread/goal/updated', + 'thread/goal/cleared', + 'thread/tokenUsage/updated', + 'turn/started', + 'hook/started', + 'turn/completed', + 'hook/completed', + 'turn/diff/updated', + 'turn/plan/updated', + 'item/started', + 'item/autoApprovalReview/started', + 'item/autoApprovalReview/completed', + 'item/completed', + 'item/agentMessage/delta', + 'item/plan/delta', + 'command/exec/outputDelta', + 'process/outputDelta', + 'process/exited', + 'item/commandExecution/outputDelta', + 'item/commandExecution/terminalInteraction', + 'item/fileChange/outputDelta', + 'item/fileChange/patchUpdated', + 'serverRequest/resolved', + 'item/mcpToolCall/progress', + 'mcpServer/oauthLogin/completed', + 'mcpServer/startupStatus/updated', + 'account/updated', + 'account/rateLimits/updated', + 'app/list/updated', + 'remoteControl/status/changed', + 'externalAgentConfig/import/completed', + 'fs/changed', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + 'item/reasoning/textDelta', + 'thread/compacted', + 'model/rerouted', + 'model/verification', + 'warning', + 'guardianWarning', + 'deprecationNotice', + 'configWarning', + 'fuzzyFileSearch/sessionUpdated', + 'fuzzyFileSearch/sessionCompleted', + 'thread/realtime/started', + 'thread/realtime/itemAdded', + 'thread/realtime/transcript/delta', + 'thread/realtime/transcript/done', + 'thread/realtime/outputAudio/delta', + 'thread/realtime/sdp', + 'thread/realtime/error', + 'thread/realtime/closed', + 'windows/worldWritableWarning', + 'windowsSandbox/setupCompleted', + 'account/login/completed', +] as const + +export const CODEX_THREAD_ITEM_VARIANTS = [ + 'userMessage', + 'hookPrompt', + 'agentMessage', + 'plan', + 'reasoning', + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'dynamicToolCall', + 'collabAgentToolCall', + 'webSearch', + 'imageView', + 'imageGeneration', + 'enteredReviewMode', + 'exitedReviewMode', + 'contextCompaction', +] as const + +export const CODEX_RUNTIME_LEAF_VALUES = { + reasoningEffort: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'], + askForApproval: ['untrusted', 'on-failure', 'on-request', 'never', 'granular'], + sandboxMode: ['read-only', 'workspace-write', 'danger-full-access'], + networkAccess: ['restricted', 'enabled'], + threadStatus: ['notLoaded', 'idle', 'systemError', 'active'], + turnStatus: ['completed', 'interrupted', 'failed', 'inProgress'], + sessionSource: ['cli', 'vscode', 'exec', 'appServer', 'custom', 'subAgent', 'unknown'], + subAgentSource: ['review', 'compact', 'thread_spawn', 'memory_consolidation', 'other'], +} as const + +export type CodexClientRequestMethod = typeof CODEX_CLIENT_REQUEST_METHODS[number] +export type CodexServerRequestMethod = typeof CODEX_SERVER_REQUEST_METHODS[number] +export type CodexServerNotificationMethod = typeof CODEX_SERVER_NOTIFICATION_METHODS[number] +export type CodexThreadItemVariant = typeof CODEX_THREAD_ITEM_VARIANTS[number] +export type CodexRuntimeLeafName = keyof typeof CODEX_RUNTIME_LEAF_VALUES diff --git a/test/fixtures/coding-cli/codex-app-server/schema-traceability.ts b/test/fixtures/coding-cli/codex-app-server/schema-traceability.ts new file mode 100644 index 000000000..0f5413adf --- /dev/null +++ b/test/fixtures/coding-cli/codex-app-server/schema-traceability.ts @@ -0,0 +1,148 @@ +import { + CODEX_CLIENT_REQUEST_METHODS, + CODEX_RUNTIME_LEAF_VALUES, + CODEX_SERVER_NOTIFICATION_METHODS, + CODEX_SERVER_REQUEST_METHODS, + CODEX_THREAD_ITEM_VARIANTS, + type CodexClientRequestMethod, + type CodexRuntimeLeafName, + type CodexServerNotificationMethod, + type CodexServerRequestMethod, + type CodexThreadItemVariant, +} from './schema-inventory.js' + +export type CodexTraceStatus = 'implemented' | 'planned' | 'unsupported' + +export type CodexTraceEntry<TName extends string> = { + name: TName + status: CodexTraceStatus + owner: string + parser: string + normalizer: string + ui: string + test: string + notes?: string +} + +const implementedClientMethods = new Set<CodexClientRequestMethod>([ + 'initialize', + 'thread/start', + 'thread/resume', + 'thread/read', + 'turn/start', + 'turn/interrupt', + 'review/start', + 'thread/fork', + 'thread/list', + 'thread/loaded/list', + 'model/list', + 'modelProvider/capabilities/read', +]) + +const visibleNotificationMethods = new Set<CodexServerNotificationMethod>([ + 'error', + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/unarchived', + 'thread/closed', + 'thread/name/updated', + 'thread/goal/updated', + 'thread/goal/cleared', + 'thread/tokenUsage/updated', + 'turn/started', + 'turn/completed', + 'turn/diff/updated', + 'turn/plan/updated', + 'item/started', + 'item/completed', + 'item/agentMessage/delta', + 'item/plan/delta', + 'item/commandExecution/outputDelta', + 'item/commandExecution/terminalInteraction', + 'item/fileChange/outputDelta', + 'item/fileChange/patchUpdated', + 'serverRequest/resolved', + 'item/mcpToolCall/progress', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + 'item/reasoning/textDelta', + 'thread/compacted', + 'model/rerouted', + 'model/verification', + 'warning', + 'guardianWarning', + 'configWarning', +]) + +export const CODEX_CLIENT_REQUEST_TRACEABILITY: readonly CodexTraceEntry<CodexClientRequestMethod>[] = + CODEX_CLIENT_REQUEST_METHODS.map((name) => ({ + name, + status: implementedClientMethods.has(name) ? 'implemented' : 'unsupported', + owner: implementedClientMethods.has(name) + ? 'server/coding-cli/codex-app-server/client.ts' + : 'server/coding-cli/codex-app-server/protocol.ts', + parser: 'server/coding-cli/codex-app-server/protocol.ts', + normalizer: implementedClientMethods.has(name) + ? 'server/fresh-agent/adapters/codex/normalize.ts' + : 'server/coding-cli/codex-app-server/protocol.ts', + ui: implementedClientMethods.has(name) + ? 'src/components/fresh-agent/FreshAgentView.tsx' + : 'clear unsupported Freshcodex action error', + test: implementedClientMethods.has(name) + ? 'test/unit/server/coding-cli/codex-app-server/client.test.ts' + : 'test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts', + notes: name === 'thread/read' + ? 'codex-cli 0.129.0 does not expose thread/turns/list; page work must be built from generated methods or a later schema.' + : undefined, + })) + +export const CODEX_SERVER_REQUEST_TRACEABILITY: readonly CodexTraceEntry<CodexServerRequestMethod>[] = + CODEX_SERVER_REQUEST_METHODS.map((name) => ({ + name, + status: 'planned', + owner: 'server/coding-cli/codex-app-server/client.ts', + parser: 'server/coding-cli/codex-app-server/protocol.ts', + normalizer: 'server/fresh-agent/adapters/codex/normalize.ts', + ui: name === 'account/chatgptAuthTokens/refresh' + ? 'runtime-global Freshcodex warning' + : 'src/components/fresh-agent/FreshAgentView.tsx', + test: 'test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts', + })) + +export const CODEX_SERVER_NOTIFICATION_TRACEABILITY: readonly CodexTraceEntry<CodexServerNotificationMethod>[] = + CODEX_SERVER_NOTIFICATION_METHODS.map((name) => ({ + name, + status: visibleNotificationMethods.has(name) ? 'planned' : 'unsupported', + owner: 'server/coding-cli/codex-app-server/client.ts', + parser: 'server/coding-cli/codex-app-server/protocol.ts', + normalizer: visibleNotificationMethods.has(name) + ? 'server/fresh-agent/adapters/codex/normalize.ts' + : 'debug-only non-visible classification', + ui: visibleNotificationMethods.has(name) + ? 'src/components/fresh-agent/FreshAgentView.tsx' + : 'no visible state effect', + test: 'test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts', + })) + +export const CODEX_THREAD_ITEM_TRACEABILITY: readonly CodexTraceEntry<CodexThreadItemVariant>[] = + CODEX_THREAD_ITEM_VARIANTS.map((name) => ({ + name, + status: 'planned', + owner: 'server/fresh-agent/adapters/codex/normalize.ts', + parser: 'server/coding-cli/codex-app-server/protocol.ts', + normalizer: 'server/fresh-agent/adapters/codex/normalize.ts', + ui: 'src/components/fresh-agent/FreshAgentTranscript.tsx', + test: 'test/unit/server/fresh-agent/codex-normalize.test.ts', + })) + +export const CODEX_RUNTIME_LEAF_TRACEABILITY: readonly CodexTraceEntry<CodexRuntimeLeafName>[] = + (Object.keys(CODEX_RUNTIME_LEAF_VALUES) as CodexRuntimeLeafName[]).map((name) => ({ + name, + status: 'implemented', + owner: 'server/coding-cli/codex-app-server/protocol.ts', + parser: 'server/coding-cli/codex-app-server/protocol.ts', + normalizer: 'server/fresh-agent/adapters/codex/normalize.ts', + ui: 'src/lib/session-type-utils.ts', + test: 'test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts', + })) diff --git a/test/fixtures/fresh-agent/claude/contract-fixtures.ts b/test/fixtures/fresh-agent/claude/contract-fixtures.ts new file mode 100644 index 000000000..8afaebc76 --- /dev/null +++ b/test/fixtures/fresh-agent/claude/contract-fixtures.ts @@ -0,0 +1,81 @@ +import type { FreshAgentSnapshot, FreshAgentTurnBody, FreshAgentTurnPage } from '../../../../shared/fresh-agent-contract.js' + +export const claudeContractTurn = { + id: 'turn:live-1', + turnId: 'turn:live-1', + messageId: 'live-1', + ordinal: 0, + source: 'live', + role: 'assistant', + timestamp: '2026-04-18T12:00:00.000Z', + model: 'claude-fixture', + summary: 'Workspace is clean.', + items: [ + { id: 'turn:live-1:item:0', kind: 'thinking', text: 'Inspecting workspace' }, + { id: 'turn:live-1:item:1', kind: 'tool_use', toolUseId: 'tool-1', name: 'Bash', input: { command: 'git status --short' } }, + { id: 'turn:live-1:item:2', kind: 'tool_result', toolUseId: 'tool-1', content: 'clean', isError: false }, + { id: 'turn:live-1:item:3', kind: 'text', text: 'Workspace is clean.' }, + ], +} satisfies FreshAgentSnapshot['turns'][number] + +export const claudeContractSnapshot = { + sessionType: 'freshclaude', + provider: 'claude', + threadId: 'sdk-claude-1', + sessionId: 'sdk-claude-1', + revision: 5, + latestTurnId: 'turn:live-1', + status: 'running', + summary: 'Workspace is clean.', + capabilities: { + send: true, + interrupt: true, + approvals: true, + questions: true, + fork: false, + }, + settings: { + model: 'claude-fixture', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a'], + }, + tokenUsage: { + inputTokens: 12, + outputTokens: 34, + totalTokens: 46, + costUsd: 1.25, + }, + pendingApprovals: [{ + requestId: 'approval-1', + toolName: 'Bash', + input: { command: 'git push' }, + }], + pendingQuestions: [], + worktrees: [], + diffs: [], + childThreads: [], + turns: [claudeContractTurn], + extensions: { + claude: { + timelineSessionId: '00000000-0000-4000-8000-000000000111', + liveSessionId: 'sdk-claude-1', + }, + }, +} satisfies FreshAgentSnapshot + +export const claudeContractTurnPage = { + sessionType: 'freshclaude', + provider: 'claude', + threadId: 'sdk-claude-1', + revision: 5, + nextCursor: null, + turns: [claudeContractTurn], +} satisfies FreshAgentTurnPage + +export const claudeContractTurnBody = { + ...claudeContractTurn, + sessionType: 'freshclaude', + provider: 'claude', + threadId: 'sdk-claude-1', + revision: 5, +} satisfies FreshAgentTurnBody diff --git a/test/fixtures/fresh-agent/claude/thread.ts b/test/fixtures/fresh-agent/claude/thread.ts new file mode 100644 index 000000000..8e2c8374a --- /dev/null +++ b/test/fixtures/fresh-agent/claude/thread.ts @@ -0,0 +1,102 @@ +import type { RestoreResolution } from '../../../../../server/agent-timeline/ledger.js' +import type { SdkSessionState } from '../../../../../server/sdk-bridge-types.js' +import type { ChatMessage } from '../../../../../server/session-history-loader.js' + +function makeMessage( + role: 'user' | 'assistant', + content: ChatMessage['content'], + options: Partial<ChatMessage> = {}, +): ChatMessage { + return { + role, + content, + timestamp: '2026-04-18T12:00:00.000Z', + ...options, + } +} + +export function makeClaudeLiveSession(overrides: Partial<SdkSessionState> = {}): SdkSessionState { + return { + sessionId: 'sdk-claude-1', + cliSessionId: '00000000-0000-4000-8000-000000000111', + resumeSessionId: 'resume-claude-1', + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a', '/tmp/plugin-b'], + status: 'running', + createdAt: 1, + messages: [ + makeMessage( + 'assistant', + [ + { type: 'thinking', thinking: 'Inspecting workspace' }, + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'git status --short' } }, + { type: 'tool_result', tool_use_id: 'tool-1', content: 'clean', is_error: false }, + { type: 'text', text: 'Workspace is clean.' }, + ], + { messageId: 'live-2' }, + ), + ], + streamingActive: false, + streamingText: '', + pendingPermissions: new Map([ + ['approval-1', { + toolName: 'Bash', + input: { command: 'git push' }, + toolUseID: 'tool-approve-1', + blockedPath: '/repo', + decisionReason: 'Needs approval', + resolve: () => ({ behavior: 'allow' }), + }], + ]), + pendingQuestions: new Map([ + ['question-1', { + originalInput: { questions: [] }, + questions: [{ + question: 'Proceed?', + header: 'Approval', + options: [{ label: 'Yes', description: 'Continue the run' }], + multiSelect: false, + }], + resolve: () => ({ behavior: 'allow' }), + }], + ]), + costUsd: 1.25, + totalInputTokens: 12, + totalOutputTokens: 34, + ...overrides, + } +} + +export function makeClaudeRestoreResolution(): Extract<RestoreResolution, { kind: 'resolved' }> { + return { + kind: 'resolved', + queryId: 'sdk-claude-1', + liveSessionId: 'sdk-claude-1', + timelineSessionId: '00000000-0000-4000-8000-000000000111', + readiness: 'merged', + revision: 5, + latestTurnId: 'turn:live-2', + turns: [ + { + turnId: 'turn:durable-1', + messageId: 'durable-1', + ordinal: 0, + source: 'durable', + message: makeMessage( + 'user', + [{ type: 'text', text: 'Summarize the repo state' }], + { messageId: 'durable-1' }, + ), + }, + { + turnId: 'turn:live-2', + messageId: 'live-2', + ordinal: 1, + source: 'live', + message: makeClaudeLiveSession().messages[0]!, + }, + ], + } +} diff --git a/test/fixtures/fresh-agent/codex/contract-fixtures.ts b/test/fixtures/fresh-agent/codex/contract-fixtures.ts new file mode 100644 index 000000000..95d796c0b --- /dev/null +++ b/test/fixtures/fresh-agent/codex/contract-fixtures.ts @@ -0,0 +1,94 @@ +import type { FreshAgentSnapshot, FreshAgentTurnBody, FreshAgentTurnPage } from '../../../../shared/fresh-agent-contract.js' + +export const codexContractTurn = { + id: 'turn-1', + turnId: 'turn-1', + messageId: 'msg-1', + ordinal: 0, + source: 'durable', + role: 'assistant', + summary: 'Codex finished a review pass', + items: [ + { id: 'turn-1:item-0', kind: 'text', text: 'Codex finished a review pass.' }, + { + id: 'turn-1:item-1', + kind: 'reasoning', + summary: ['Reviewed changed files'], + content: ['Inspected the diff and checked the tests.'], + text: 'Reviewed changed files', + }, + ], +} satisfies FreshAgentSnapshot['turns'][number] + +export const codexContractSnapshot = { + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + revision: 7, + status: 'idle', + summary: 'Codex finished a review pass', + capabilities: { + send: true, + interrupt: true, + approvals: true, + questions: true, + fork: true, + worktrees: true, + diffs: true, + childThreads: true, + }, + tokenUsage: { + inputTokens: 10, + outputTokens: 6, + cachedTokens: 2, + totalTokens: 18, + contextTokens: 18, + compactPercent: 4, + }, + pendingApprovals: [{ + requestId: 17, + toolName: 'shell', + input: { command: 'git diff' }, + }], + pendingQuestions: [{ + requestId: 'question-1', + questions: [{ + question: 'Proceed?', + header: 'Approval', + options: [{ label: 'Yes', description: 'Continue' }], + multiSelect: false, + }], + }], + worktrees: [{ id: 'wt-1', path: '/repo/.worktrees/task-1', branch: 'feature/task-1' }], + diffs: [{ id: 'diff-1', path: 'src/app.ts', title: 'src/app.ts' }], + childThreads: [{ id: 'child-1', threadId: 'thread-child-1', origin: 'subagent', title: 'Review shell' }], + turns: [codexContractTurn], + extensions: { + codex: { + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + sourceVersion: '0.129.0', + }, + }, +} satisfies FreshAgentSnapshot + +export const codexContractTurnPage = { + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + revision: 7, + nextCursor: null, + backwardsCursor: null, + turns: [codexContractTurn], + bodies: { + 'turn-1': codexContractTurn, + }, +} satisfies FreshAgentTurnPage + +export const codexContractTurnBody = { + ...codexContractTurn, + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + revision: 7, +} satisfies FreshAgentTurnBody diff --git a/test/fixtures/fresh-agent/codex/thread.ts b/test/fixtures/fresh-agent/codex/thread.ts new file mode 100644 index 000000000..c455dc057 --- /dev/null +++ b/test/fixtures/fresh-agent/codex/thread.ts @@ -0,0 +1,74 @@ +export const codexRichSnapshotFixture = { + provider: 'codex', + threadId: 'thread-codex-1', + status: 'idle', + revision: 7, + summary: 'Implement the fresh-agent shell', + tokenUsage: { + inputTokens: 120, + outputTokens: 45, + cachedTokens: 12, + totalTokens: 177, + contextTokens: 177, + compactPercent: 18, + }, + worktrees: [ + { + id: 'wt-1', + path: '/repo/.worktrees/fresh-agent-platform', + branch: 'feature/fresh-agent-platform', + }, + ], + diffs: [ + { + id: 'diff-1', + path: 'src/components/fresh-agent/FreshAgentView.tsx', + title: 'FreshAgentView.tsx', + }, + ], + childThreads: [ + { + id: 'child-1', + threadId: 'thread-codex-child-1', + origin: 'subagent', + title: 'Review shell states', + }, + ], + extension: { + codex: { + review: { + id: 'review-1', + status: 'pending', + }, + fork: { + parentThreadId: 'thread-parent-1', + }, + }, + }, + turns: [ + { + id: 'turn-1', + turnId: 'turn-1', + messageId: 'msg-1', + ordinal: 0, + source: 'durable', + role: 'user', + summary: 'Implement the fresh-agent shell', + items: [ + { id: 'turn-1:item-0', kind: 'text', text: 'Implement the fresh-agent shell' }, + ], + }, + { + id: 'turn-2', + turnId: 'turn-2', + messageId: 'msg-2', + ordinal: 1, + source: 'live', + role: 'assistant', + summary: 'Created worktree and queued review', + items: [ + { id: 'turn-2:item-0', kind: 'text', text: 'Created worktree and queued review.' }, + ], + }, + ], +} as const diff --git a/test/fixtures/fresh-agent/contract-traceability.ts b/test/fixtures/fresh-agent/contract-traceability.ts new file mode 100644 index 000000000..6e5e26a53 --- /dev/null +++ b/test/fixtures/fresh-agent/contract-traceability.ts @@ -0,0 +1,33 @@ +import { FRESH_AGENT_CONTRACT_SCHEMA_NAMES } from '../../../shared/fresh-agent-contract.js' + +export type FreshAgentContractTraceEntry = { + schema: typeof FRESH_AGENT_CONTRACT_SCHEMA_NAMES[number] + producers: readonly string[] + serverParser: string + clientParser: string + stateOwner: string + uiConsumer: string + fixtures: readonly string[] + tests: readonly string[] +} + +export const FRESH_AGENT_CONTRACT_TRACEABILITY: readonly FreshAgentContractTraceEntry[] = + FRESH_AGENT_CONTRACT_SCHEMA_NAMES.map((schema) => ({ + schema, + producers: [ + 'server/fresh-agent/adapters/claude/normalize.ts', + 'server/fresh-agent/adapters/codex/normalize.ts', + ], + serverParser: 'server/fresh-agent/runtime-manager.ts', + clientParser: 'src/lib/api.ts', + stateOwner: 'src/store/freshAgentSlice.ts', + uiConsumer: 'src/components/fresh-agent/FreshAgentView.tsx', + fixtures: [ + 'test/fixtures/fresh-agent/claude/contract-fixtures.ts', + 'test/fixtures/fresh-agent/codex/contract-fixtures.ts', + ], + tests: [ + 'test/unit/shared/fresh-agent-contract.test.ts', + 'test/unit/shared/fresh-agent-contract-traceability.test.ts', + ], + })) diff --git a/test/helpers/coding-cli/fake-codex-launch-planner.ts b/test/helpers/coding-cli/fake-codex-launch-planner.ts index f669d025f..f1f32712c 100644 --- a/test/helpers/coding-cli/fake-codex-launch-planner.ts +++ b/test/helpers/coding-cli/fake-codex-launch-planner.ts @@ -3,8 +3,6 @@ export const DEFAULT_CODEX_REMOTE_WS_URL = 'ws://127.0.0.1:43123' export class FakeCodexLaunchSidecar { adoptCalls: Array<{ terminalId: string; generation: number }> = [] shutdownCalls = 0 - waitForLoadedThreadCalls: Array<{ threadId: string; options?: { timeoutMs?: number; pollMs?: number } }> = [] - waitForLoadedThreadError: Error | null = null shutdownError: Error | null = null shutdownStarted = false private lifecycleLossHandlers = new Set<(event: unknown) => void>() @@ -13,10 +11,6 @@ export class FakeCodexLaunchSidecar { this.adoptCalls.push(input) } - async listLoadedThreads() { - return ['thread-new-1'] - } - async shutdown() { if (this.shutdownStarted) return this.shutdownStarted = true @@ -24,11 +18,6 @@ export class FakeCodexLaunchSidecar { if (this.shutdownError) throw this.shutdownError } - async waitForLoadedThread(threadId: string, options?: { timeoutMs?: number; pollMs?: number }) { - this.waitForLoadedThreadCalls.push({ threadId, options }) - if (this.waitForLoadedThreadError) throw this.waitForLoadedThreadError - } - onLifecycleLoss(handler: (event: unknown) => void) { this.lifecycleLossHandlers.add(handler) return () => this.lifecycleLossHandlers.delete(handler) @@ -44,6 +33,7 @@ export class FakeCodexLaunchSidecar { export class FakeCodexLaunchPlanner { planCreateCalls: any[] = [] sidecar = new FakeCodexLaunchSidecar() + private failuresRemaining = 0 constructor( private readonly plan: { @@ -56,8 +46,16 @@ export class FakeCodexLaunchPlanner { }, ) {} + failNext(count: number) { + this.failuresRemaining = Math.max(0, count) + } + async planCreate(input: any) { this.planCreateCalls.push(input) + if (this.failuresRemaining > 0) { + this.failuresRemaining -= 1 + throw new Error('fake Codex launch failed') + } return { ...this.plan, sidecar: this.plan.sidecar ?? this.sidecar, diff --git a/test/helpers/coding-cli/real-session-contract-harness.ts b/test/helpers/coding-cli/real-session-contract-harness.ts index 1e5644896..c1274486a 100644 --- a/test/helpers/coding-cli/real-session-contract-harness.ts +++ b/test/helpers/coding-cli/real-session-contract-harness.ts @@ -74,6 +74,7 @@ type OpencodeFacts = { canonicalIdentity: 'session-id' runEventSessionIdMatchesDbId: boolean busyStatusUsesAuthoritativeSessionId: boolean + attachFormatJsonEmitsEvents: boolean titleOnResumeMutatesStoredTitle: boolean sessionSubcommands: string[] } @@ -213,7 +214,7 @@ async function listFilesRecursive(rootDir: string): Promise<string[]> { async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise<unknown> { return waitFor(`HTTP JSON at ${url}`, async () => { try { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { return undefined } @@ -224,6 +225,16 @@ async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise<unknown }, timeoutMs, 200) } +async function fetchWithTimeout(url: string, timeoutMs = 2_000): Promise<Response> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + function parseJsonLines(text: string): unknown[] { return text .split(/\r?\n/) @@ -514,23 +525,32 @@ export class ProbeWorkspace { stderr += chunk }) + const exitPromise = new Promise<ExitSummary>((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { + resolve({ + code, + signal, + }) + }) + }) + const waitForExit = (timeoutMs = 30_000) => new Promise<ExitSummary>((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Timed out waiting for process ${command} (${child.pid}) to exit.`)) }, timeoutMs) - child.once('error', (error) => { - clearTimeout(timeout) - reject(error) - }) - child.once('close', (code, signal) => { - clearTimeout(timeout) - resolve({ - code, - signal, - }) - }) + exitPromise.then( + (summary) => { + clearTimeout(timeout) + resolve(summary) + }, + (error) => { + clearTimeout(timeout) + reject(error) + }, + ) }) const stop = async () => { @@ -1268,13 +1288,17 @@ export async function waitForFileSizeIncrease(filePath: string, previousSize: nu } export async function fetchJson(url: string): Promise<any> { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { throw new Error(`Expected a successful response from ${url}, received ${response.status}.`) } return response.json() } +export async function waitForJsonResponse(url: string): Promise<any> { + return waitForHttpJson(url) +} + export async function waitForHttpBusyStatus(url: string, sessionId: string): Promise<Record<string, { type: string }>> { return waitFor(`OpenCode busy status for ${sessionId}`, async () => { const payload = await fetchJson(url).catch(() => undefined) diff --git a/test/integration/real/codex-app-server-readiness-contract.test.ts b/test/integration/real/codex-app-server-readiness-contract.test.ts index baa93e84e..8c222a6f0 100644 --- a/test/integration/real/codex-app-server-readiness-contract.test.ts +++ b/test/integration/real/codex-app-server-readiness-contract.test.ts @@ -242,7 +242,7 @@ describe('real Codex app-server durable readiness contract', () => { await actor.initialize() const resumed = await actor.resumeThread({ threadId: durableThreadId, cwd: process.cwd() }) - expect(resumed.thread.id).toBe(durableThreadId) + expect(resumed.threadId).toBe(durableThreadId) const readiness = await waitForLifecycle( lifecycle, diff --git a/test/integration/real/coding-cli-session-contract.test.ts b/test/integration/real/coding-cli-session-contract.test.ts index 699242022..982d6e461 100644 --- a/test/integration/real/coding-cli-session-contract.test.ts +++ b/test/integration/real/coding-cli-session-contract.test.ts @@ -10,7 +10,6 @@ import { captureCodexBootstrapEvents, captureCodexResumeBootstrapEvents, extractCodexResumeId, - fetchJson, findClaudeTranscript, findCodexSessionArtifacts, loadCodingCliSessionContractNote, @@ -27,6 +26,7 @@ import { waitForFileSizeIncrease, waitForAnyHttpBusyStatus, waitForHttpHealthy, + waitForJsonResponse, waitForJsonLine, waitForOpencodeDbSession, } from '../../helpers/coding-cli/real-session-contract-harness.js' @@ -451,13 +451,13 @@ describe.sequential('coding cli real provider session contract', () => { healthy: true, version: note.providers.opencode.version, }) - expect(await fetchJson(statusUrl)).toEqual({}) + expect(await waitForJsonResponse(statusUrl)).toEqual({}) const attachedRun = await workspace.spawnProcess( opencodePath, [ 'run', - 'Explain the purpose of this repository in one sentence.', + 'Write ten short sentences about terminal multiplexers. Do not use bullets.', '--format', 'json', '--dangerously-skip-permissions', @@ -470,12 +470,22 @@ describe.sequential('coding cli real provider session contract', () => { ) const busyStatusPromise = waitForAnyHttpBusyStatus(statusUrl) - const attachedStepStart = await waitForJsonLine(attachedRun, (value) => value?.type === 'step_start', 60_000) - const attachedSessionId = attachedStepStart.sessionID as string const busyStatus = await busyStatusPromise - expect(busyStatus.sessionId).toBe(attachedSessionId) - expect(busyStatus.payload[attachedSessionId]).toEqual({ type: 'busy' }) + expect(busyStatus.payload[busyStatus.sessionId]).toEqual({ type: 'busy' }) + const attachedDbRow = await waitForOpencodeDbSession(homes.dbPath, busyStatus.sessionId) + expect(attachedDbRow.id).toBe(busyStatus.sessionId) expect((await attachedRun.waitForExit(120_000)).code).toBe(0) + const attachedStdout = attachedRun.stdout().trim() + if (note.providers.opencode.attachFormatJsonEmitsEvents) { + expect(attachedStdout).not.toBe('') + const attachedEventLines = attachedStdout + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + expect(attachedEventLines.some((event) => event.sessionID === busyStatus.sessionId)).toBe(true) + } else { + expect(attachedStdout).toBe('') + } const titledRun = await workspace.spawnProcess( opencodePath, diff --git a/test/integration/server/codex-real-provider-smoke.test.ts b/test/integration/server/codex-real-provider-smoke.test.ts index 8fb0e4295..975210abe 100644 --- a/test/integration/server/codex-real-provider-smoke.test.ts +++ b/test/integration/server/codex-real-provider-smoke.test.ts @@ -171,10 +171,6 @@ async function prepareRealProviderCodexHome(targetCodexHome: string): Promise<{ return { sessionId: selected.id } } -function stripAnsi(value: string): string { - return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') -} - afterEach(async () => { await Promise.all([...registries].map(async (registry) => { registries.delete(registry) @@ -197,13 +193,6 @@ describe('Codex real-provider smoke', () => { const { sessionId } = await prepareRealProviderCodexHome(codexHome) const registry = new TerminalRegistry() registries.add(registry) - const outputChunks: string[] = [] - const outputHandler = (event: { data?: unknown }) => { - if (typeof event.data === 'string') { - outputChunks.push(stripAnsi(event.data)) - } - } - registry.on('terminal.output.raw', outputHandler) const previousCodexHome = process.env.CODEX_HOME process.env.CODEX_HOME = codexHome const planner = new CodexLaunchPlanner(() => new CodexAppServerRuntime({ @@ -235,14 +224,6 @@ describe('Codex real-provider smoke', () => { }, }) await resumePlan.sidecar.adopt({ terminalId: term.terminalId, generation: 0 }) - try { - await resumePlan.sidecar.waitForLoadedThread(sessionId, { timeoutMs: 20_000, pollMs: 250 }) - } catch (error) { - const outputTail = outputChunks.join('').slice(-1_000) - throw new Error( - `${error instanceof Error ? error.message : String(error)}\nCodex TUI output before failure:\n${outputTail}`, - ) - } const ownershipId = await readOwnershipId(metadataDir) await registry.killAndWait(term.terminalId) @@ -255,7 +236,6 @@ describe('Codex real-provider smoke', () => { } else { process.env.CODEX_HOME = previousCodexHome } - registry.off('terminal.output.raw', outputHandler) } }, 60_000) }) diff --git a/test/integration/server/codex-session-flow.test.ts b/test/integration/server/codex-session-flow.test.ts index e18eeb89e..ab8d16182 100644 --- a/test/integration/server/codex-session-flow.test.ts +++ b/test/integration/server/codex-session-flow.test.ts @@ -1,10 +1,11 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import fsp from 'fs/promises' import http from 'http' import os from 'os' import path from 'path' import express from 'express' import WebSocket from 'ws' +import { createRequire } from 'module' import { WsHandler } from '../../../server/ws-handler.js' import { TerminalRegistry } from '../../../server/terminal-registry.js' import { CodexAppServerRuntime } from '../../../server/coding-cli/codex-app-server/runtime.js' @@ -30,7 +31,7 @@ vi.mock('../../../server/logger', () => { child: vi.fn(), } logger.child.mockReturnValue(logger) - return { logger } + return { logger, sessionLifecycleLogger: logger } }) process.env.AUTH_TOKEN = 'test-token' @@ -40,16 +41,25 @@ const FAKE_APP_SERVER_PATH = path.resolve( process.cwd(), 'test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs', ) +const requireForFixture = createRequire(import.meta.url) +const WS_MODULE_PATH = requireForFixture.resolve('ws') async function writeFakeCodexExecutable(binaryPath: string) { const script = `#!/usr/bin/env node const fs = require('fs') +let WebSocket function appendJsonLine(filePath, value) { if (!filePath) return fs.appendFileSync(filePath, JSON.stringify(value) + '\\n', 'utf8') } +function remoteUrlFromArgs(args) { + const index = args.indexOf('--remote') + if (index === -1 || index === args.length - 1) return undefined + return args[index + 1] +} + const argLogPath = process.env.FAKE_CODEX_ARG_LOG if (argLogPath) { fs.writeFileSync(argLogPath, JSON.stringify(process.argv.slice(2)), 'utf8') @@ -70,14 +80,64 @@ if (process.env.FAKE_CODEX_FIRST_LAUNCH_CLAIM_PATH) { } } +const remoteUrl = remoteUrlFromArgs(process.argv.slice(2)) +let remoteSocket +let remoteMessageId = 1 +let remoteThreadId +let remoteReady = false +if (process.env.FAKE_CODEX_CONNECT_REMOTE === '1' && remoteUrl) { + WebSocket = require(${JSON.stringify(WS_MODULE_PATH)}) + setTimeout(() => { + remoteSocket = new WebSocket(remoteUrl) + remoteSocket.on('open', () => { + remoteSocket.send(JSON.stringify({ + id: remoteMessageId++, + method: 'thread/start', + params: { cwd: process.cwd() }, + })) + }) + remoteSocket.on('message', (raw) => { + appendJsonLine(process.env.FAKE_CODEX_REMOTE_LOG, { + pid: process.pid, + message: JSON.parse(raw.toString('utf8')), + }) + const message = JSON.parse(raw.toString('utf8')) + const threadId = message && message.result && message.result.thread && message.result.thread.id + if (threadId) { + remoteThreadId = threadId + remoteReady = true + } + }) + remoteSocket.on('error', (error) => { + appendJsonLine(process.env.FAKE_CODEX_REMOTE_LOG, { + pid: process.pid, + error: error.message, + }) + }) + }, Number(process.env.FAKE_CODEX_REMOTE_CONNECT_DELAY_MS || 0)) +} + process.stdin.on('data', (chunk) => { appendJsonLine(process.env.FAKE_CODEX_INPUT_LOG, { pid: process.pid, data: chunk.toString('utf8'), }) + if (remoteSocket && remoteSocket.readyState === WebSocket.OPEN && remoteReady && remoteThreadId) { + remoteSocket.send(JSON.stringify({ + id: remoteMessageId++, + method: 'turn/start', + params: { + threadId: remoteThreadId, + input: chunk.toString('utf8'), + }, + })) + } }) -process.on('SIGTERM', () => process.exit(0)) +process.on('SIGTERM', () => { + if (remoteSocket) remoteSocket.close() + process.exit(0) +}) process.stdout.write('codex remote attached\\n') if (process.env.FAKE_CODEX_STAY_ALIVE === '1') { if ( @@ -142,6 +202,12 @@ function waitForMessage( }) } +async function killAllTerminals(registry: TerminalRegistry): Promise<void> { + await Promise.all( + registry.list().map((term) => registry.killAndWait(term.terminalId).catch(() => false)), + ) +} + async function waitForFile(filePath: string, timeoutMs = 3_000): Promise<void> { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -176,15 +242,6 @@ async function isProcessAlive(pid: number): Promise<boolean> { } } -async function waitForProcessExit(pid: number, timeoutMs = 5_000): Promise<void> { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (!(await isProcessAlive(pid))) return - await new Promise((resolve) => setTimeout(resolve, 25)) - } - throw new Error(`Timed out waiting for process ${pid} to exit`) -} - async function readJsonLines(filePath: string): Promise<any[]> { const raw = await fsp.readFile(filePath, 'utf8').catch(() => '') return raw @@ -309,6 +366,7 @@ describe('Codex Session Flow Integration', () => { }) beforeEach(async () => { + await killAllTerminals(registry) delete process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR await planner?.shutdown() await Promise.all([...runtimes].map((runtime) => runtime.shutdown())) @@ -330,6 +388,10 @@ describe('Codex Session Flow Integration', () => { await fsp.rm(argLogPath, { force: true }) }) + afterEach(async () => { + await killAllTerminals(registry) + }) + afterAll(async () => { if (previousCodexCmd === undefined) { delete process.env.CODEX_CMD @@ -351,7 +413,11 @@ describe('Codex Session Flow Integration', () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) - it('starts the exact codex thread before PTY spawn and launches the TUI in remote mode', async () => { + it('launches fresh Codex in remote mode without treating the bootstrap id as durable', async () => { + const launchLogPath = path.join(tempDir, 'fresh-codex-launches.jsonl') + const previousLaunchLog = process.env.FAKE_CODEX_LAUNCH_LOG + process.env.FAKE_CODEX_LAUNCH_LOG = launchLogPath + await fsp.rm(launchLogPath, { force: true }) const ws = await createAuthenticatedWs(port) try { @@ -373,28 +439,144 @@ describe('Codex Session Flow Integration', () => { throw new Error(`terminal.create failed: ${created.message}`) } - expect(created.effectiveResumeSessionId).toBe('thread-new-1') + expect(created.effectiveResumeSessionId).toBeUndefined() const record = registry.get(created.terminalId) - expect(record?.resumeSessionId).toBe('thread-new-1') + expect(record?.resumeSessionId).toBeUndefined() - await waitForFile(argLogPath) - const recordedArgs = JSON.parse(await fsp.readFile(argLogPath, 'utf8')) + const launch = await waitForJsonLine(launchLogPath, (line) => line.pid === record?.pty.pid) + const recordedArgs = launch.args expect(recordedArgs.slice(0, 2)).toEqual([ '--remote', expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/), ]) - expect(recordedArgs).toContain('resume') - expect(recordedArgs).toContain('thread-new-1') - expect(recordedArgs).toContain('tui.notification_method=bel') + const appsFlagIndex = recordedArgs.indexOf('features.apps=false') + expect(appsFlagIndex).toBeGreaterThan(0) + expect(recordedArgs[appsFlagIndex - 1]).toBe('-c') + expect(recordedArgs).not.toContain('resume') + expect(recordedArgs).not.toContain('thread-new-1') + expect(recordedArgs).not.toContain('tui.notification_method=bel') + expect(recordedArgs).not.toContain("tui.notifications=['agent-turn-complete']") expect(recordedArgs).not.toContain('--model') expect(recordedArgs).not.toContain('--sandbox') } finally { await closeWebSocket(ws) + if (previousLaunchLog === undefined) delete process.env.FAKE_CODEX_LAUNCH_LOG + else process.env.FAKE_CODEX_LAUNCH_LOG = previousLaunchLog } }) - it('restores a persisted Codex session without calling thread/resume on the app-server', async () => { + it('captures a fresh Codex restore identity from the fake TUI and promotes it after turn completion', async () => { + const testDir = await fsp.mkdtemp(path.join(tempDir, 'fresh-durable-flow-')) + const rolloutPath = path.join(testDir, 'rollout.jsonl') + const remoteLogPath = path.join(testDir, 'remote.jsonl') + const launchLogPath = path.join(testDir, 'codex-launches.jsonl') + const previousConnectRemote = process.env.FAKE_CODEX_CONNECT_REMOTE + const previousRemoteDelay = process.env.FAKE_CODEX_REMOTE_CONNECT_DELAY_MS + const previousRemoteLog = process.env.FAKE_CODEX_REMOTE_LOG + const previousStayAlive = process.env.FAKE_CODEX_STAY_ALIVE + const previousLaunchLog = process.env.FAKE_CODEX_LAUNCH_LOG + process.env.FAKE_CODEX_CONNECT_REMOTE = '1' + process.env.FAKE_CODEX_REMOTE_CONNECT_DELAY_MS = '25' + process.env.FAKE_CODEX_REMOTE_LOG = remoteLogPath + process.env.FAKE_CODEX_STAY_ALIVE = '1' + process.env.FAKE_CODEX_LAUNCH_LOG = launchLogPath + process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ + threadStartThreadId: 'thread-fake-tui-durable', + threadStartRolloutPath: rolloutPath, + writeRolloutOnMethods: { + 'turn/start': { + path: rolloutPath, + threadId: 'thread-fake-tui-durable', + }, + }, + notificationsAfterMethods: { + 'turn/start': [{ + method: 'turn/completed', + params: { + threadId: 'thread-fake-tui-durable', + turnId: 'turn-1', + status: 'completed', + }, + }], + }, + }) + + const ws = await createAuthenticatedWs(port) + + try { + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId: 'test-req-codex-fake-tui', + mode: 'codex', + cwd: testDir, + })) + + const created = await waitForMessage( + ws, + (msg) => ( + msg.requestId === 'test-req-codex-fake-tui' + && (msg.type === 'terminal.created' || msg.type === 'error') + ), + ) + if (created.type === 'error') { + throw new Error(`terminal.create failed: ${created.message}`) + } + + await vi.waitFor(() => { + expect(registry.get(created.terminalId)?.codexDurability).toMatchObject({ + state: 'captured_pre_turn', + candidate: { + candidateThreadId: 'thread-fake-tui-durable', + rolloutPath, + }, + }) + }) + await waitForJsonLine(remoteLogPath, (line) => ( + line.pid === registry.get(created.terminalId)?.pty.pid + && line.message?.result?.thread?.id === 'thread-fake-tui-durable' + )) + + ws.send(JSON.stringify({ + type: 'terminal.input', + terminalId: created.terminalId, + data: 'hello from fake TUI\r', + })) + + await vi.waitFor(() => { + expect(registry.get(created.terminalId)?.resumeSessionId).toBe('thread-fake-tui-durable') + }) + expect(registry.get(created.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-fake-tui-durable', + }) + expect(registry.list().find((term) => term.terminalId === created.terminalId)?.sessionRef).toEqual({ + provider: 'codex', + sessionId: 'thread-fake-tui-durable', + }) + expect(await fsp.readFile(rolloutPath, 'utf8')).toContain('"thread-fake-tui-durable"') + } finally { + await closeWebSocket(ws) + if (previousConnectRemote === undefined) delete process.env.FAKE_CODEX_CONNECT_REMOTE + else process.env.FAKE_CODEX_CONNECT_REMOTE = previousConnectRemote + if (previousRemoteDelay === undefined) delete process.env.FAKE_CODEX_REMOTE_CONNECT_DELAY_MS + else process.env.FAKE_CODEX_REMOTE_CONNECT_DELAY_MS = previousRemoteDelay + if (previousRemoteLog === undefined) delete process.env.FAKE_CODEX_REMOTE_LOG + else process.env.FAKE_CODEX_REMOTE_LOG = previousRemoteLog + if (previousStayAlive === undefined) delete process.env.FAKE_CODEX_STAY_ALIVE + else process.env.FAKE_CODEX_STAY_ALIVE = previousStayAlive + if (previousLaunchLog === undefined) delete process.env.FAKE_CODEX_LAUNCH_LOG + else process.env.FAKE_CODEX_LAUNCH_LOG = previousLaunchLog + delete process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR + await fsp.rm(testDir, { recursive: true, force: true }) + } + }) + + it('restores a persisted Codex session from canonical sessionRef', async () => { + const launchLogPath = path.join(tempDir, 'restore-codex-launches.jsonl') + const previousLaunchLog = process.env.FAKE_CODEX_LAUNCH_LOG + process.env.FAKE_CODEX_LAUNCH_LOG = launchLogPath + await fsp.rm(launchLogPath, { force: true }) process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ loadedThreadIds: ['thread-existing-1'], overrides: { @@ -415,7 +597,11 @@ describe('Codex Session Flow Integration', () => { requestId: 'test-req-codex-restore', mode: 'codex', cwd: tempDir, - resumeSessionId: 'thread-existing-1', + restore: true, + sessionRef: { + provider: 'codex', + sessionId: 'thread-existing-1', + }, })) const created = await waitForMessage( @@ -429,13 +615,13 @@ describe('Codex Session Flow Integration', () => { throw new Error(`terminal.create failed: ${created.message}`) } - expect(created.effectiveResumeSessionId).toBe('thread-existing-1') + expect(created.effectiveResumeSessionId).toBeUndefined() const record = registry.get(created.terminalId) expect(record?.resumeSessionId).toBe('thread-existing-1') - await waitForFile(argLogPath) - const recordedArgs = JSON.parse(await fsp.readFile(argLogPath, 'utf8')) + const launch = await waitForJsonLine(launchLogPath, (line) => line.pid === record?.pty.pid) + const recordedArgs = launch.args expect(recordedArgs.slice(0, 2)).toEqual([ '--remote', expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/), @@ -445,6 +631,8 @@ describe('Codex Session Flow Integration', () => { } finally { await closeWebSocket(ws) delete process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR + if (previousLaunchLog === undefined) delete process.env.FAKE_CODEX_LAUNCH_LOG + else process.env.FAKE_CODEX_LAUNCH_LOG = previousLaunchLog } }) @@ -452,23 +640,19 @@ describe('Codex Session Flow Integration', () => { const testDir = await fsp.mkdtemp(path.join(tempDir, 'recovery-retire-')) const metadataDir = path.join(testDir, 'metadata') const oldNativePidFile = path.join(testDir, 'old-native.pid') - const replacementNativePidFile = path.join(testDir, 'replacement-native.pid') const launchLogPath = path.join(testDir, 'codex-launches.jsonl') const inputLogPath = path.join(testDir, 'codex-input.jsonl') - const oldSidecarShutdownSignalPath = path.join(testDir, 'old-sidecar-shutdown.signal') const firstLaunchClaimPath = path.join(testDir, 'first-tui.claim') await fsp.mkdir(metadataDir, { recursive: true }) const previousStayAlive = process.env.FAKE_CODEX_STAY_ALIVE const previousLaunchLog = process.env.FAKE_CODEX_LAUNCH_LOG const previousInputLog = process.env.FAKE_CODEX_INPUT_LOG - const previousExitWhenFileExists = process.env.FAKE_CODEX_EXIT_WHEN_FILE_EXISTS const previousFirstLaunchOnly = process.env.FAKE_CODEX_EXIT_WATCH_FIRST_LAUNCH_ONLY const previousFirstLaunchClaim = process.env.FAKE_CODEX_FIRST_LAUNCH_CLAIM_PATH process.env.FAKE_CODEX_STAY_ALIVE = '1' process.env.FAKE_CODEX_LAUNCH_LOG = launchLogPath process.env.FAKE_CODEX_INPUT_LOG = inputLogPath - process.env.FAKE_CODEX_EXIT_WHEN_FILE_EXISTS = oldSidecarShutdownSignalPath process.env.FAKE_CODEX_EXIT_WATCH_FIRST_LAUNCH_ONLY = '1' process.env.FAKE_CODEX_FIRST_LAUNCH_CLAIM_PATH = firstLaunchClaimPath @@ -481,8 +665,6 @@ describe('Codex Session Flow Integration', () => { FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ spawnNativeChild: true, nativePidFile: oldNativePidFile, - wrapperLeavesNativeOnSigterm: true, - signalFileOnSigterm: oldSidecarShutdownSignalPath, delayExitOnSigtermMs: 200, loadedThreadIds: ['thread-existing-1'], }), @@ -496,7 +678,6 @@ describe('Codex Session Flow Integration', () => { env: { FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ spawnNativeChild: true, - nativePidFile: replacementNativePidFile, wrapperLeavesNativeOnSigterm: true, loadedThreadIds: ['thread-existing-1'], }), @@ -507,15 +688,15 @@ describe('Codex Session Flow Integration', () => { const oldPlanner = new CodexLaunchPlanner(oldRuntime) const replacementPlanner = new CodexLaunchPlanner(replacementRuntime) let terminalId: string | undefined + let oldPtyPid: number | undefined try { const oldPlan = await oldPlanner.planCreate({ resumeSessionId: 'thread-existing-1' }) const oldNativePid = await waitForPidFile(oldNativePidFile) + expect(oldNativePid).toEqual(expect.any(Number)) const recovery = { planCreate: vi.fn(() => replacementPlanner.planCreate({ resumeSessionId: 'thread-existing-1' })), retryDelayMs: 0, - readinessTimeoutMs: 1_000, - readinessPollMs: 25, } const term = registry.create({ mode: 'codex', @@ -530,25 +711,28 @@ describe('Codex Session Flow Integration', () => { } as any, }) terminalId = term.terminalId - const oldPtyPid = term.pty.pid + await oldPlan.sidecar.adopt({ terminalId: term.terminalId, generation: 0 }) + oldPtyPid = term.pty.pid await waitForJsonLine(launchLogPath, (line) => line.pid === oldPtyPid) await (registry as any).runCodexRecoveryAttempt( registry.get(term.terminalId), 'thread-existing-1', ) - - const replacementNativePid = await waitForPidFile(replacementNativePidFile) - await waitForProcessExit(oldNativePid) - await waitForProcessExit(oldPtyPid) - expect(await isProcessAlive(replacementNativePid)).toBe(true) + const replacementLaunch = await waitForJsonLine( + launchLogPath, + (line) => line.pid !== oldPtyPid && Array.isArray(line.args) && line.args.includes('thread-existing-1'), + ) const latest = registry.get(term.terminalId) + expect(latest?.status).toBe('running') + expect(latest?.resumeSessionId).toBe('thread-existing-1') + expect(registry.findRunningTerminalBySession('codex', 'thread-existing-1')?.terminalId).toBe(term.terminalId) const replacementPtyPid = latest?.pty.pid expect(replacementPtyPid).toEqual(expect.any(Number)) - expect(replacementPtyPid).not.toBe(oldPtyPid) + expect(replacementPtyPid).toBe(replacementLaunch.pid) - expect(registry.input(term.terminalId, 'after recovery replacement\n')).toBe(true) + expect(registry.input(term.terminalId, 'after recovery replacement\n')).toEqual({ status: 'written' }) await waitForJsonLine( inputLogPath, (line) => line.pid === replacementPtyPid && line.data.includes('after recovery replacement'), @@ -556,6 +740,13 @@ describe('Codex Session Flow Integration', () => { const inputLines = await readJsonLines(inputLogPath) expect(inputLines.some((line) => line.pid === oldPtyPid && line.data.includes('after recovery replacement'))).toBe(false) } finally { + if (oldPtyPid && await isProcessAlive(oldPtyPid)) { + try { + process.kill(oldPtyPid, 'SIGKILL') + } catch { + // Best-effort cleanup for a fake PTY process that can outlive the assertion window under parallel load. + } + } if (terminalId) { await registry.killAndWait(terminalId).catch(() => undefined) } @@ -571,8 +762,6 @@ describe('Codex Session Flow Integration', () => { else process.env.FAKE_CODEX_LAUNCH_LOG = previousLaunchLog if (previousInputLog === undefined) delete process.env.FAKE_CODEX_INPUT_LOG else process.env.FAKE_CODEX_INPUT_LOG = previousInputLog - if (previousExitWhenFileExists === undefined) delete process.env.FAKE_CODEX_EXIT_WHEN_FILE_EXISTS - else process.env.FAKE_CODEX_EXIT_WHEN_FILE_EXISTS = previousExitWhenFileExists if (previousFirstLaunchOnly === undefined) delete process.env.FAKE_CODEX_EXIT_WATCH_FIRST_LAUNCH_ONLY else process.env.FAKE_CODEX_EXIT_WATCH_FIRST_LAUNCH_ONLY = previousFirstLaunchOnly if (previousFirstLaunchClaim === undefined) delete process.env.FAKE_CODEX_FIRST_LAUNCH_CLAIM_PATH diff --git a/test/integration/server/opencode-session-flow.test.ts b/test/integration/server/opencode-session-flow.test.ts index b070d5407..62cd3078b 100644 --- a/test/integration/server/opencode-session-flow.test.ts +++ b/test/integration/server/opencode-session-flow.test.ts @@ -453,8 +453,9 @@ describe('opencode session flow (integration)', () => { } }) - it('promotes an OpenCode terminal only after authoritative control data exposes a canonical session id', async () => { + it('promotes and completes an OpenCode terminal only after live same-stream idle', async () => { vi.useFakeTimers() + const turnCompletions: Array<{ terminalId: string; sessionId: string; at: number }> = [] const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { const url = String(input) if (url.endsWith('/global/health')) { @@ -466,7 +467,17 @@ describe('opencode session flow (integration)', () => { }) } if (url.endsWith('/event')) { - return createSseResponse([{ type: 'server.connected', properties: {} }]) + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.status', + properties: { + sessionID: OPENCODE_SESSION_ID, + status: { type: 'busy' }, + }, + }, + { type: 'session.idle', properties: { sessionID: OPENCODE_SESSION_ID } }, + ]) } throw new Error(`Unexpected URL: ${url}`) }) @@ -475,6 +486,9 @@ describe('opencode session flow (integration)', () => { registry: registry as any, fetchImpl: fetchImpl as typeof fetch, random: () => 0, + onTurnComplete: (payload) => { + turnCompletions.push(payload) + }, }) try { @@ -497,6 +511,13 @@ describe('opencode session flow (integration)', () => { reason: 'association', }), ]) + expect(turnCompletions).toEqual([ + expect.objectContaining({ + terminalId: record.terminalId, + sessionId: OPENCODE_SESSION_ID, + at: expect.any(Number), + }), + ]) } finally { wiring.dispose() vi.useRealTimers() diff --git a/test/integration/server/platform-api.test.ts b/test/integration/server/platform-api.test.ts index 2f10cc786..2ae182888 100644 --- a/test/integration/server/platform-api.test.ts +++ b/test/integration/server/platform-api.test.ts @@ -182,6 +182,17 @@ describe('Platform API', () => { delete process.env.KILROY_ENABLED }) + + it('returns a featureFlags object that keeps kilroy separate from fresh-agent defaults', async () => { + const res = await request(app) + .get('/api/platform') + .set('x-auth-token', TEST_AUTH_TOKEN) + + expect(res.status).toBe(200) + expect(res.body.featureFlags).toEqual(expect.objectContaining({ + kilroy: false, + })) + }) }) describe('GET /api/version', () => { diff --git a/test/integration/server/session-metadata-api.test.ts b/test/integration/server/session-metadata-api.test.ts index edce6e191..9e9ede209 100644 --- a/test/integration/server/session-metadata-api.test.ts +++ b/test/integration/server/session-metadata-api.test.ts @@ -1,169 +1,47 @@ -// @vitest-environment node -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import express, { type Express } from 'express' +import express from 'express' import request from 'supertest' -import fsp from 'fs/promises' -import path from 'path' -import os from 'os' -import { SessionMetadataStore } from '../../../server/session-metadata-store.js' -import { createSessionsRouter } from '../../../server/sessions-router.js' - -const TEST_AUTH_TOKEN = 'test-auth-token' +import { describe, expect, it, vi } from 'vitest' -describe('POST /api/session-metadata', () => { - let app: Express - let tempDir: string - let sessionMetadataStore: SessionMetadataStore - let mockRefresh: ReturnType<typeof vi.fn> - - beforeEach(async () => { - tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'session-metadata-api-test-')) - sessionMetadataStore = new SessionMetadataStore(tempDir) - mockRefresh = vi.fn().mockResolvedValue(undefined) +import { createSessionsRouter } from '../../../server/sessions-router.js' - app = express() +describe('session-metadata API', () => { + it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { + const entries = new Map<string, { derivedTitle?: string; sessionType?: string }>() + const sessionMetadataStore = { + get: vi.fn(async (provider: string, sessionId: string) => entries.get(`${provider}:${sessionId}`)), + set: vi.fn(async (provider: string, sessionId: string, entry: { derivedTitle?: string; sessionType?: string }) => { + const key = `${provider}:${sessionId}` + entries.set(key, { ...(entries.get(key) ?? {}), ...entry }) + }), + } + await sessionMetadataStore.set('codex', 'sess-1', { derivedTitle: 'Sticky title' }) + + const app = express() app.use(express.json()) - - // Auth middleware - app.use('/api', (req, res, next) => { - const token = req.headers['x-auth-token'] - if (token !== TEST_AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' }) - next() - }) - - // Mount sessions router with minimal deps app.use('/api', createSessionsRouter({ configStore: { - patchSessionOverride: vi.fn().mockResolvedValue({}), - deleteSession: vi.fn().mockResolvedValue(undefined), - }, + getSettings: vi.fn(), + patchSessionOverride: vi.fn(), + deleteSession: vi.fn(), + } as any, codingCliIndexer: { - getProjects: () => [], - refresh: mockRefresh, + getProjects: vi.fn().mockReturnValue([]), + refresh: vi.fn().mockResolvedValue(undefined), }, codingCliProviders: [], - perfConfig: { slowSessionRefreshMs: 500 }, - sessionMetadataStore, + perfConfig: { slowSessionRefreshMs: 0 }, + sessionMetadataStore: sessionMetadataStore as any, + validCliProviders: ['codex'], })) - }) - - afterEach(async () => { - await fsp.rm(tempDir, { recursive: true, force: true }) - }) - it('stores session metadata, triggers refresh, and returns ok', async () => { - const res = await request(app) + const response = await request(app) .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) + .send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' }) - expect(res.status).toBe(200) - expect(res.body).toEqual({ ok: true }) - - // Verify the metadata was actually persisted - const stored = await sessionMetadataStore.get('claude', 'sess-123') - expect(stored).toEqual({ sessionType: 'agent' }) - - // Verify the indexer was refreshed so sessions API reflects the change - expect(mockRefresh).toHaveBeenCalled() - }) - - it('preserves derivedTitle when the session metadata API updates sessionType', async () => { - await sessionMetadataStore.set('claude', 'sess-123', { derivedTitle: 'Sticky title' }) - - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(200) - expect(await sessionMetadataStore.get('claude', 'sess-123')).toEqual({ - sessionType: 'agent', + expect(response.status).toBe(200) + expect(entries.get('codex:sess-1')).toEqual({ derivedTitle: 'Sticky title', + sessionType: 'freshcodex', }) }) - - it('returns 400 when provider is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when sessionId is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionType: 'agent' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when sessionType is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when body is empty', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({}) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when provider is a non-string type', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 123, sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when provider is not a known CLI provider', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'unknown-cli', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when sessionId is an object', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: {}, sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when sessionType is an empty string', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: '' }) - - expect(res.status).toBe(400) - }) - - it('requires authentication', async () => { - const res = await request(app) - .post('/api/session-metadata') - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(401) - }) }) diff --git a/test/integration/server/settings-api.test.ts b/test/integration/server/settings-api.test.ts index 2be7771e8..dfa88d1a7 100644 --- a/test/integration/server/settings-api.test.ts +++ b/test/integration/server/settings-api.test.ts @@ -168,6 +168,21 @@ describe('Settings API Integration', () => { }) }) + it('PATCH /api/settings accepts freshAgent settings while preserving the legacy alias', async () => { + const res = await request(app) + .patch('/api/settings') + .set('x-auth-token', TEST_AUTH_TOKEN) + .send({ + freshAgent: { + defaultPlugins: ['fs', 'search'], + }, + }) + + expect(res.status).toBe(200) + expect(res.body.freshAgent.defaultPlugins).toEqual(['fs', 'search']) + expect(res.body.agentChat.defaultPlugins).toEqual(['fs', 'search']) + }) + it('PATCH /api/settings preserves runtime CLI providers outside the built-in defaults', async () => { const res = await request(app) .patch('/api/settings') diff --git a/test/integration/server/tabs-registry-store.persistence.test.ts b/test/integration/server/tabs-registry-store.persistence.test.ts index cd37c1e63..5d4ed1294 100644 --- a/test/integration/server/tabs-registry-store.persistence.test.ts +++ b/test/integration/server/tabs-registry-store.persistence.test.ts @@ -1,7 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { promises as fs } from 'fs' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { createReadStream, promises as fs } from 'fs' +import crypto from 'crypto' import os from 'os' import path from 'path' +import readline from 'readline' import { createTabsRegistryStore } from '../../../server/tabs-registry/store.js' import type { RegistryTabRecord } from '../../../server/tabs-registry/types.js' @@ -26,10 +28,86 @@ function makeRecord(overrides: Partial<RegistryTabRecord>): RegistryTabRecord { } } -describe('tabs registry store persistence', () => { +async function lineCount(file: string): Promise<number> { + const input = createReadStream(file) + const lines = readline.createInterface({ input, crlfDelay: Infinity }) + let count = 0 + for await (const _line of lines) { + count += 1 + } + return count +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]` + const entries = Object.entries(value as Record<string, unknown>) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(',')}}` +} + +function objectFor(value: unknown) { + const raw = stableStringify(value) + const sha256 = crypto.createHash('sha256').update(raw).digest('hex') + return { + raw, + ref: { + path: `objects/${sha256}.json`, + sha256, + bytes: Buffer.byteLength(raw, 'utf-8'), + }, + } +} + +function clientSnapshotKey(deviceId: string, clientInstanceId: string): string { + return `${Buffer.from(deviceId, 'utf-8').toString('base64url')}:${Buffer.from(clientInstanceId, 'utf-8').toString('base64url')}` +} + +function pushHash(input: { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + records: unknown[] +}): string { + return crypto.createHash('sha256').update(stableStringify(input)).digest('hex') +} + +function makeClientSnapshotObject(input: { + deviceId: string + deviceLabel: string + clientInstanceId: string + snapshotRevision: number + snapshotReceivedAt: number + records: RegistryTabRecord[] + lastPushPayloadHash?: string +}) { + const openSnapshotPayloadHash = pushHash({ + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + records: input.records, + }) + return objectFor({ + deviceId: input.deviceId, + deviceLabel: input.deviceLabel, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + lastPushPayloadHash: input.lastPushPayloadHash ?? openSnapshotPayloadHash, + openSnapshotPayloadHash, + snapshotReceivedAt: input.snapshotReceivedAt, + records: input.records, + }) +} + +describe('tabs registry compact persistence', () => { let tempDir: string + let now = NOW beforeEach(async () => { + now = NOW tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tabs-registry-persist-')) }) @@ -37,32 +115,1075 @@ describe('tabs registry store persistence', () => { await fs.rm(tempDir, { recursive: true, force: true }) }) - it('rehydrates records from append-only JSONL log', async () => { - const writer = createTabsRegistryStore(tempDir, { now: () => NOW }) + it('persists manifest-referenced objects and rehydrates without active JSONL growth', async () => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) const openRecord = makeRecord({ tabKey: 'local:open-1', tabId: 'open-1', deviceId: 'local-device', + deviceLabel: 'local', status: 'open', revision: 3, updatedAt: NOW - 5_000, }) const closedRecord = makeRecord({ - tabKey: 'remote:closed-1', + tabKey: 'local:closed-1', tabId: 'closed-1', - deviceId: 'remote-device', + deviceId: 'local-device', + deviceLabel: 'local', status: 'closed', revision: 5, closedAt: NOW - 5000, updatedAt: NOW - 5000, }) - await writer.upsert(openRecord) - await writer.upsert(closedRecord) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [openRecord, closedRecord], + }) + + await expect(fs.stat(path.join(tempDir, 'tabs-registry.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).resolves.toBeTruthy() + + const manifest = JSON.parse(await fs.readFile(path.join(tempDir, 'v1', 'manifest.json'), 'utf-8')) as { + openSnapshots: Record<string, { path: string }> + closedTombstones: { path: string } + } + const [snapshotRef] = Object.values(manifest.openSnapshots) + const snapshotObject = JSON.parse(await fs.readFile(path.join(tempDir, 'v1', snapshotRef.path), 'utf-8')) as { + records: RegistryTabRecord[] + lastPushRecords?: RegistryTabRecord[] + } + expect(snapshotObject.records.map((record) => record.tabKey)).toEqual([openRecord.tabKey]) + expect(snapshotObject).not.toHaveProperty('lastPushRecords') + const closedObject = JSON.parse(await fs.readFile(path.join(tempDir, 'v1', manifest.closedTombstones.path), 'utf-8')) as Record<string, RegistryTabRecord> + expect(Object.keys(closedObject)).toEqual([closedRecord.tabKey]) + + const reader = await createTabsRegistryStore(tempDir, { now: () => now }) + const result = await reader.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen.map((record) => record.tabKey)).toEqual([openRecord.tabKey]) + expect(result.closed.map((record) => record.tabKey)).toEqual([closedRecord.tabKey]) + expect(result.devices.map((device) => device.deviceId)).toContain('local-device') + }) + + it('ignores orphaned object and temp files on startup and garbage-collects them after commit', async () => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:open-1', tabId: 'open-1', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + const orphanPath = path.join(tempDir, 'v1', 'objects', '0'.repeat(64) + '.json') + const tmpPath = path.join(tempDir, 'v1', 'tmp', 'orphan.tmp') + await fs.mkdir(path.dirname(orphanPath), { recursive: true }) + await fs.mkdir(path.dirname(tmpPath), { recursive: true }) + await fs.writeFile(orphanPath, '{"orphan":true}', 'utf-8') + await fs.writeFile(tmpPath, '{"temp":true}', 'utf-8') + + const reader = await createTabsRegistryStore(tempDir, { now: () => now }) + const result = await reader.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen).toHaveLength(1) + + await reader.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [ + makeRecord({ tabKey: 'local:open-2', tabId: 'open-2', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + await expect(fs.stat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(tmpPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('streams legacy JSONL migration, resolves latest per tab before pruning, and archives only after publish', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.mkdir(tempDir, { recursive: true }) + const oldOpen = makeRecord({ + tabKey: 'remote:a', + tabId: 'a', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'open', + revision: 99, + updatedAt: NOW - 40 * 24 * 60 * 60 * 1000, + }) + const oldClosedWinner = makeRecord({ + ...oldOpen, + status: 'closed', + revision: 1, + updatedAt: NOW - 35 * 24 * 60 * 60 * 1000, + closedAt: NOW - 35 * 24 * 60 * 60 * 1000, + }) + const freshOpen = makeRecord({ + tabKey: 'remote:b', + tabId: 'b', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'open', + updatedAt: NOW - 365 * 24 * 60 * 60 * 1000, + }) + await fs.writeFile(legacyPath, `${JSON.stringify(oldOpen)}\n${JSON.stringify(oldClosedWinner)}\n${JSON.stringify(freshOpen)}\n`, 'utf-8') + + const store = await createTabsRegistryStore(tempDir, { now: () => now }) + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.remoteOpen.map((record) => record.tabKey)).toEqual(['remote:b']) + expect(result.closed).toHaveLength(0) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).resolves.toBeTruthy() + await expect(fs.stat(legacyPath)).rejects.toMatchObject({ code: 'ENOENT' }) + const files = await fs.readdir(tempDir) + expect(files.some((file) => /^tabs-registry\.jsonl\.migrated-/.test(file))).toBe(true) + }) + + it('fails migration with a clear cap error before unbounded memory growth', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.mkdir(tempDir, { recursive: true }) + const largePanePayload = 'x'.repeat(300 * 1024) + await fs.writeFile(legacyPath, `${JSON.stringify(makeRecord({ + tabKey: 'remote:large', + tabId: 'large', + deviceId: 'remote-device', + deviceLabel: 'remote', + panes: [{ paneId: 'pane-1', kind: 'terminal', payload: { largePanePayload } }], + }))}\n`, 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxLegacyLineBytes: 256 * 1024 }, + })).rejects.toThrow(/legacy.*line.*256 kib|migration.*cap/i) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await lineCount(legacyPath)).toBe(1) + }) + + it('fails migration when valid retained legacy records exceed the retained-byte budget', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.mkdir(tempDir, { recursive: true }) + const largePanePayload = 'x'.repeat(40 * 1024) + const lines = Array.from({ length: 4 }, (_, i) => JSON.stringify(makeRecord({ + tabKey: `remote:large-${i}`, + tabId: `large-${i}`, + deviceId: 'remote-device', + deviceLabel: 'remote', + panes: [{ paneId: 'pane-1', kind: 'terminal', payload: { largePanePayload } }], + }))) + await fs.writeFile(legacyPath, `${lines.join('\n')}\n`, 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { + maxLegacyLineBytes: 256 * 1024, + maxMigrationRetainedBytes: 100 * 1024, + }, + })).rejects.toThrow(/retained-byte cap/i) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await lineCount(legacyPath)).toBe(4) + }) + + it('fails legacy migration on valid records that exceed pane-count caps', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.writeFile(legacyPath, `${JSON.stringify(makeRecord({ + tabKey: 'remote:pane-cap', + tabId: 'pane-cap', + deviceId: 'remote-device', + deviceLabel: 'remote', + paneCount: 21, + panes: Array.from({ length: 21 }, (_, i) => ({ paneId: `pane-${i}`, kind: 'terminal', payload: {} })), + }))}\n`, 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/20 panes|migration/i) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await lineCount(legacyPath)).toBe(1) + }) + + it('fails legacy migration when migrated open device snapshots exceed the cap', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + const lines = Array.from({ length: 3 }, (_, i) => JSON.stringify(makeRecord({ + tabKey: `device-${i}:tab`, + tabId: `tab-${i}`, + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + }))) + await fs.writeFile(legacyPath, `${lines.join('\n')}\n`, 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxClientSnapshotRefs: 2 }, + })).rejects.toThrow(/migrated.*snapshots|client snapshots/i) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('fails legacy migration when one synthetic device snapshot exceeds the open-record cap', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + const lines = Array.from({ length: 3 }, (_, i) => JSON.stringify(makeRecord({ + tabKey: `remote-device:tab-${i}`, + tabId: `tab-${i}`, + deviceId: 'remote-device', + deviceLabel: 'remote', + }))) + await fs.writeFile(legacyPath, `${lines.join('\n')}\n`, 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxOpenRecordsPerClientSnapshot: 2 }, + })).rejects.toThrow(/open records|client snapshot|migration/i) + await expect(fs.stat(path.join(tempDir, 'v1', 'manifest.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('normalizes legacy synthetic snapshot record labels to their snapshot device label', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.writeFile(legacyPath, [ + JSON.stringify(makeRecord({ + tabKey: 'remote-device:old', + tabId: 'old', + deviceId: 'remote-device', + deviceLabel: 'old-label', + updatedAt: NOW - 2_000, + })), + JSON.stringify(makeRecord({ + tabKey: 'remote-device:new', + tabId: 'new', + deviceId: 'remote-device', + deviceLabel: 'new-label', + updatedAt: NOW - 1_000, + })), + '', + ].join('\n'), 'utf-8') + + const migrated = await createTabsRegistryStore(tempDir, { now: () => now }) + const result = await migrated.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(new Set(result.remoteOpen.map((record) => record.deviceLabel))).toEqual(new Set(['old-label'])) + }) + + it('rejects corrupt compact state with a clear error instead of serving empty data', async () => { + await fs.mkdir(path.join(tempDir, 'v1'), { recursive: true }) + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), '{"version":1,"openSnapshots":{}}', 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/tabs registry compact state.*invalid|manifest/i) + }) + + it('rejects compact open snapshot objects that contain closed records', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const closedRecord = makeRecord({ + tabKey: 'local:closed-in-open', + tabId: 'closed-in-open', + deviceId: 'local-device', + deviceLabel: 'local', + status: 'closed', + closedAt: NOW, + updatedAt: NOW, + }) + const snapshot = { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + lastPushPayloadHash: '0'.repeat(64), + openSnapshotPayloadHash: '0'.repeat(64), + snapshotReceivedAt: NOW, + records: [closedRecord], + } + const snapshotObject = objectFor(snapshot) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('local-device', 'window-a')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/open snapshot.*open records|compact state/i) + }) + + it('rejects compact closed tombstones that are not closed or whose keys do not match record tab keys', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const openInClosed = makeRecord({ + tabKey: 'actual:open', + tabId: 'open', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'open', + }) + const closedKeyMismatch = makeRecord({ + tabKey: 'actual:closed', + tabId: 'closed', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'closed', + closedAt: NOW, + updatedAt: NOW, + }) + const closedObject = objectFor({ + 'manifest-open': openInClosed, + 'manifest-closed': closedKeyMismatch, + }) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: {}, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/closed tombstone|compact state/i) + }) + + it('rejects compact open snapshots whose records exceed caps or do not belong to the snapshot identity', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const mismatchedRecord = makeRecord({ + tabKey: 'remote:open', + tabId: 'open', + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + } as Partial<RegistryTabRecord>) + const tooManyPanes = makeRecord({ + tabKey: 'local:pane-cap', + tabId: 'pane-cap', + deviceId: 'local-device', + deviceLabel: 'local', + paneCount: 21, + panes: Array.from({ length: 21 }, (_, i) => ({ paneId: `pane-${i}`, kind: 'terminal', payload: {} })), + }) + const snapshot = { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + lastPushPayloadHash: pushHash({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [mismatchedRecord, tooManyPanes], + }), + openSnapshotPayloadHash: pushHash({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [mismatchedRecord, tooManyPanes], + }), + snapshotReceivedAt: NOW, + records: [mismatchedRecord, tooManyPanes], + } + const snapshotObject = objectFor(snapshot) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('local-device', 'window-a')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/snapshot.*record|20 panes|compact state/i) + }) + + it('rejects compact manifests with non-v1 liveness settings', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: {}, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 525600, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/manifest|compact state/i) + }) + + it('rejects compact snapshots whose open snapshot hash does not match their open records', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const record = makeRecord({ + tabKey: 'local:open', + tabId: 'open', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + } as Partial<RegistryTabRecord>) + const openSnapshotPayloadHash = pushHash({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [record], + }) + const snapshot = { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + lastPushPayloadHash: openSnapshotPayloadHash, + openSnapshotPayloadHash: '1'.repeat(64), + snapshotReceivedAt: NOW, + records: [record], + } + const snapshotObject = objectFor(snapshot) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('local-device', 'window-a')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/payload hash|compact state/i) + }) + + it('rejects oversized compact manifest files before reading the manifest body', async () => { + await fs.mkdir(path.join(tempDir, 'v1'), { recursive: true }) + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), 'x'.repeat(1024), 'utf-8') + + const readSpy = vi.spyOn(fs, 'readFile') + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxSerializedManifestBytes: 64 } as any, + })).rejects.toThrow(/manifest.*64 bytes|compact state/i) + const manifestReads = readSpy.mock.calls.filter(([file]) => String(file).endsWith(`${path.sep}v1${path.sep}manifest.json`)) + readSpy.mockRestore() + expect(manifestReads).toHaveLength(0) + }) + + it('rejects compact state when manifest key does not match snapshot identity', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const record = makeRecord({ + tabKey: 'local:open', + tabId: 'open', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + } as Partial<RegistryTabRecord>) + const snapshot = { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + lastPushPayloadHash: pushHash({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [record], + }), + openSnapshotPayloadHash: pushHash({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [record], + }), + snapshotReceivedAt: NOW, + records: [record], + } + const snapshotObject = objectFor(snapshot) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('local-device', 'window-a')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/snapshot key.*identity|compact state/i) + }) + + it('rejects manifest object refs that exceed per-object caps before reading the object body', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const oversizedSha = 'a'.repeat(64) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + await fs.writeFile(path.join(tempDir, 'v1', 'objects', `${oversizedSha}.json`), '{}', 'utf-8') + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { + [clientSnapshotKey('local-device', 'window-a')]: { + path: `objects/${oversizedSha}.json`, + sha256: oversizedSha, + bytes: 600 * 1024, + }, + }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/object.*512 KiB|compact state/i) + }) + + it('rejects excessive compact manifest snapshot refs before reading object bodies', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + const openSnapshots: Record<string, ReturnType<typeof objectFor>['ref']> = {} + for (let i = 0; i < 3; i += 1) { + const record = makeRecord({ + tabKey: `device-${i}:tab`, + tabId: `tab-${i}`, + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + clientInstanceId: 'window', + } as Partial<RegistryTabRecord>) + const snapshotObject = makeClientSnapshotObject({ + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + clientInstanceId: 'window', + snapshotRevision: 1, + snapshotReceivedAt: NOW, + records: [record], + }) + openSnapshots[clientSnapshotKey(`device-${i}`, 'window')] = snapshotObject.ref + await fs.writeFile(path.join(tempDir, 'v1', snapshotObject.ref.path), snapshotObject.raw, 'utf-8') + } + for (const object of [closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify({ + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + }), 'utf-8') + + const readSpy = vi.spyOn(fs, 'readFile') + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxClientSnapshotRefs: 2 }, + })).rejects.toThrow(/client snapshots|compact state/i) + const objectReads = readSpy.mock.calls.filter(([file]) => String(file).includes(`${path.sep}v1${path.sep}objects${path.sep}`)) + readSpy.mockRestore() + expect(objectReads).toHaveLength(0) + }) + + it('rejects excessive compact manifest aggregate bytes before reading object bodies', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const record = makeRecord({ + tabKey: 'local:large-ref', + tabId: 'large-ref', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + panes: [{ paneId: 'pane-1', kind: 'terminal', payload: { text: 'x'.repeat(1024) } }], + } as Partial<RegistryTabRecord>) + const snapshotObject = makeClientSnapshotObject({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + snapshotReceivedAt: NOW, + records: [record], + }) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify({ + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('local-device', 'window-a')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + }), 'utf-8') + + const readSpy = vi.spyOn(fs, 'readFile') + await expect(createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxCompactStateBytes: 200 }, + })).rejects.toThrow(/compact state exceeds|compact state/i) + const objectReads = readSpy.mock.calls.filter(([file]) => String(file).includes(`${path.sep}v1${path.sep}objects${path.sep}`)) + readSpy.mockRestore() + expect(objectReads).toHaveLength(0) + }) + + it('rejects manifest object refs whose filename is not the content hash', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const closedObject = objectFor({}) + const devicesObject = objectFor({}) + const clientRevisionsObject = objectFor({}) + const mismatchedPath = `objects/${'1'.repeat(64)}.json` + await fs.writeFile(path.join(tempDir, 'v1', closedObject.ref.path), closedObject.raw, 'utf-8') + await fs.writeFile(path.join(tempDir, 'v1', mismatchedPath), closedObject.raw, 'utf-8') + await fs.writeFile(path.join(tempDir, 'v1', devicesObject.ref.path), devicesObject.raw, 'utf-8') + await fs.writeFile(path.join(tempDir, 'v1', clientRevisionsObject.ref.path), clientRevisionsObject.raw, 'utf-8') + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: {}, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: { ...closedObject.ref, path: mismatchedPath }, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/content hash|compact state|manifest/i) + }) + + it('rejects devices metadata whose keys do not match device ids', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const closedObject = objectFor({}) + const devicesObject = objectFor({ + 'manifest-device': { deviceId: 'actual-device', deviceLabel: 'actual', lastSeenAt: NOW }, + }) + const clientRevisionsObject = objectFor({}) + for (const object of [closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: {}, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).rejects.toThrow(/devices.*key|compact state/i) + }) + + it('validates an existing content-hash object before referencing it in a new manifest', async () => { + const store = await createTabsRegistryStore(tempDir, { now: () => now }) + const record = makeRecord({ + tabKey: 'local:open-1', + tabId: 'open-1', + deviceId: 'local-device', + deviceLabel: 'local', + }) + const storedRecord = { ...record, clientInstanceId: 'window-a' } + const expectedSnapshotHash = crypto.createHash('sha256').update(stableStringify({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [storedRecord], + })).digest('hex') + const snapshot = { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + lastPushPayloadHash: expectedSnapshotHash, + openSnapshotPayloadHash: expectedSnapshotHash, + snapshotReceivedAt: NOW, + records: [storedRecord], + } + const expectedObject = objectFor(snapshot) + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + await fs.writeFile(path.join(tempDir, 'v1', expectedObject.ref.path), '{"wrong":true}', 'utf-8') + + await expect(store.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [record], + })).rejects.toThrow(/existing.*object.*hash/i) + + await expect(createTabsRegistryStore(tempDir, { now: () => now })).resolves.toBeTruthy() + }) + + it('reuses unchanged object refs without rereading compact objects during heartbeat commits', async () => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) + const localRecord = makeRecord({ + tabKey: 'local:open', + tabId: 'local', + deviceId: 'local-device', + deviceLabel: 'local', + }) + const remoteRecord = makeRecord({ + tabKey: 'remote:open', + tabId: 'remote', + deviceId: 'remote-device', + deviceLabel: 'remote', + }) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [localRecord], + }) + await writer.replaceClientSnapshot({ + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [remoteRecord], + }) + + const readSpy = vi.spyOn(fs, 'readFile') + now += 1 + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [localRecord], + }) + + const objectReads = readSpy.mock.calls.filter(([file]) => String(file).includes(`${path.sep}v1${path.sep}objects${path.sep}`)) + readSpy.mockRestore() + expect(objectReads).toHaveLength(0) + }) + + it('does not reread closed tombstone objects when a heartbeat repeats retained closed records unchanged', async () => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) + const openRecord = makeRecord({ + tabKey: 'local:open', + tabId: 'open', + deviceId: 'local-device', + deviceLabel: 'local', + }) + const closedRecord = makeRecord({ + tabKey: 'local:closed', + tabId: 'closed', + deviceId: 'local-device', + deviceLabel: 'local', + status: 'closed', + updatedAt: NOW - 500, + closedAt: NOW - 500, + }) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [openRecord, closedRecord], + }) + + const readSpy = vi.spyOn(fs, 'readFile') + now += 1 + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [openRecord, closedRecord], + }) + + const objectReads = readSpy.mock.calls.filter(([file]) => String(file).includes(`${path.sep}v1${path.sep}objects${path.sep}`)) + readSpy.mockRestore() + expect(objectReads).toHaveLength(0) + }) + + it('keeps query pure while ignoring closed tombstones beyond server retention for conflict resolution', async () => { + await fs.mkdir(path.join(tempDir, 'v1', 'objects'), { recursive: true }) + const openRecord = makeRecord({ + tabKey: 'remote:aged-conflict', + tabId: 'aged-conflict', + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + status: 'open', + revision: 1, + updatedAt: NOW, + } as Partial<RegistryTabRecord>) + const expiredClosedRecord = makeRecord({ + ...openRecord, + status: 'closed', + revision: 5, + updatedAt: NOW + 1_000, + closedAt: NOW - 31 * 24 * 60 * 60 * 1000, + clientInstanceId: 'remote-closer', + } as Partial<RegistryTabRecord>) + const snapshotObject = makeClientSnapshotObject({ + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + snapshotReceivedAt: NOW, + records: [openRecord], + }) + const closedObject = objectFor({ [expiredClosedRecord.tabKey]: expiredClosedRecord }) + const devicesObject = objectFor({ + 'remote-device': { deviceId: 'remote-device', deviceLabel: 'remote', lastSeenAt: NOW }, + }) + const clientRevisionsObject = objectFor({ + [clientSnapshotKey('remote-device', 'remote-window')]: { + deviceId: 'remote-device', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + lastSeenAt: NOW, + }, + }) + for (const object of [snapshotObject, closedObject, devicesObject, clientRevisionsObject]) { + await fs.writeFile(path.join(tempDir, 'v1', object.ref.path), object.raw, 'utf-8') + } + const manifest = { + version: 1, + manifestRevision: 1, + committedAt: NOW, + openSnapshots: { [clientSnapshotKey('remote-device', 'remote-window')]: snapshotObject.ref }, + clientRevisions: clientRevisionsObject.ref, + closedTombstones: closedObject.ref, + devices: devicesObject.ref, + settings: { openSnapshotTtlMinutes: 30, deviceDisplayTtlDays: 7, maxClosedRetentionDays: 30 }, + } + await fs.writeFile(path.join(tempDir, 'v1', 'manifest.json'), stableStringify(manifest), 'utf-8') + const beforeManifest = await fs.readFile(path.join(tempDir, 'v1', 'manifest.json'), 'utf-8') + const reader = await createTabsRegistryStore(tempDir, { now: () => now }) + + const result = await reader.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + const afterManifest = await fs.readFile(path.join(tempDir, 'v1', 'manifest.json'), 'utf-8') + + expect(result.remoteOpen.map((record) => record.tabKey)).toEqual(['remote:aged-conflict']) + expect(result.closed).toHaveLength(0) + expect(afterManifest).toBe(beforeManifest) + }) + + it.each([ + ['object-write'], + ['object-rename'], + ['manifest-write'], + ['manifest-rename'], + ] as const)('keeps memory and startup-visible disk unchanged after %s failure', async (failAt) => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:before', tabId: 'before', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + writer.setTestFailurePoint(failAt) + await expect(writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [ + makeRecord({ tabKey: 'local:after', tabId: 'after', deviceId: 'local-device', deviceLabel: 'local' }), + ], + })).rejects.toThrow(/injected/i) - const reader = createTabsRegistryStore(tempDir, { now: () => NOW }) - const result = await reader.query({ deviceId: 'local-device' }) - expect(result.localOpen.some((record) => record.tabKey === openRecord.tabKey)).toBe(true) - expect(result.closed.some((record) => record.tabKey === closedRecord.tabKey)).toBe(true) + const live = await writer.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(live.localOpen.map((record) => record.tabKey)).toEqual(['local:before']) + + const restarted = await createTabsRegistryStore(tempDir, { now: () => now }) + const rehydrated = await restarted.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(rehydrated.localOpen.map((record) => record.tabKey)).toEqual(['local:before']) + }) + + it('allows concurrent queries to see old or new committed state, never a partial mutation', async () => { + const store = await createTabsRegistryStore(tempDir, { now: () => now }) + await store.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:before', tabId: 'before', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + let releaseCommit: (() => void) | undefined + store.setTestBeforeManifestPublishHook(() => new Promise<void>((resolve) => { + releaseCommit = resolve + })) + + const writePromise = store.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [ + makeRecord({ tabKey: 'local:after', tabId: 'after', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + await vi.waitFor(() => { + expect(releaseCommit).toBeTypeOf('function') + }) + + const during = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(during.localOpen.map((record) => record.tabKey)).toEqual(['local:before']) + + releaseCommit?.() + await writePromise + const after = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(after.localOpen.map((record) => record.tabKey)).toEqual(['local:after']) + }) + + it('loads committed state and accepts same-revision retry after manifest publish succeeds before ack', async () => { + const writer = await createTabsRegistryStore(tempDir, { now: () => now }) + const beforeRecord = makeRecord({ + tabKey: 'local:before', + tabId: 'before', + deviceId: 'local-device', + deviceLabel: 'local', + }) + const afterRecord = makeRecord({ + tabKey: 'local:after', + tabId: 'after', + deviceId: 'local-device', + deviceLabel: 'local', + }) + const afterClosedRecord = makeRecord({ + tabKey: 'local:closed-after', + tabId: 'closed-after', + deviceId: 'local-device', + deviceLabel: 'local', + status: 'closed', + updatedAt: NOW, + closedAt: NOW, + }) + await writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [beforeRecord], + }) + ;(writer as any).setTestAfterManifestPublishHook(async () => { + throw new Error('Injected tabs registry after manifest publish failure') + }) + + await expect(writer.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [afterRecord, afterClosedRecord], + })).rejects.toThrow(/after manifest publish/i) + + const restarted = await createTabsRegistryStore(tempDir, { now: () => now }) + const rehydrated = await restarted.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(rehydrated.localOpen.map((record) => record.tabKey)).toEqual(['local:after']) + expect(rehydrated.closed.map((record) => record.tabKey)).toEqual(['local:closed-after']) + + await expect(restarted.replaceClientSnapshot({ + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [afterRecord, afterClosedRecord], + })).resolves.toMatchObject({ accepted: true, openRecords: 1, closedRecords: 1 }) }) }) diff --git a/test/server/agent-panes-write.test.ts b/test/server/agent-panes-write.test.ts index c05f0ac45..f54990fbf 100644 --- a/test/server/agent-panes-write.test.ts +++ b/test/server/agent-panes-write.test.ts @@ -3,6 +3,7 @@ import express from 'express' import request from 'supertest' import { createAgentApiRouter } from '../../server/agent-api/router' import { FakeCodexLaunchPlanner } from '../helpers/coding-cli/fake-codex-launch-planner.js' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../../server/coding-cli/codex-app-server/restore-decision.js' it('splits a pane horizontally', async () => { const app = express() @@ -127,6 +128,36 @@ it('kills the created Codex terminal when split adoption fails after registry.cr expect(attachPaneContent).not.toHaveBeenCalled() }) +it('rejects raw Codex resume ids before splitting a pane', async () => { + const app = express() + app.use(express.json()) + const splitPane = vi.fn(() => ({ newPaneId: 'pane_new', tabId: 'tab_1' })) + const attachPaneContent = vi.fn() + const registryCreate = vi.fn(() => ({ terminalId: 'term_new' })) + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + app.use('/api', createAgentApiRouter({ + layoutStore: { splitPane, attachPaneContent }, + registry: { create: registryCreate }, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/panes/pane_1/split').send({ + direction: 'horizontal', + mode: 'codex', + resumeSessionId: 'thread-raw-split', + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + status: 'error', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + expect(codexLaunchPlanner.planCreateCalls).toEqual([]) + expect(splitPane).not.toHaveBeenCalled() + expect(registryCreate).not.toHaveBeenCalled() + expect(attachPaneContent).not.toHaveBeenCalled() +}) + it('kills the created Codex split terminal without waiting for readiness when shutdown admission closes after adoption', async () => { const app = express() app.use(express.json()) @@ -158,13 +189,12 @@ it('kills the created Codex split terminal without waiting for readiness when sh const res = await request(app).post('/api/panes/pane_1/split').send({ direction: 'horizontal', mode: 'codex', - resumeSessionId: 'thread-split-shutdown', + sessionRef: { provider: 'codex', sessionId: 'thread-split-shutdown' }, }) expect(res.status).toBe(500) expect(res.body.message).toContain('Server is shutting down') expect(codexLaunchPlanner.sidecar.adoptCalls).toEqual([{ terminalId: 'term_split_shutdown', generation: 0 }]) - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toEqual([]) expect(registry.publishCodexSidecar).not.toHaveBeenCalled() expect(registry.killAndWait).toHaveBeenCalledWith('term_split_shutdown') expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) @@ -278,6 +308,38 @@ it('kills the created Codex terminal when respawn adoption fails after registry. expect(attachPaneContent).not.toHaveBeenCalled() }) +it('rejects raw Codex resume ids before respawning a pane', async () => { + const app = express() + app.use(express.json()) + const attachPaneContent = vi.fn() + const registryCreate = vi.fn(() => ({ terminalId: 'term_new' })) + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + const resolveTarget = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) + app.use('/api', createAgentApiRouter({ + layoutStore: { + attachPaneContent, + resolveTarget, + } as any, + registry: { create: registryCreate }, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/panes/pane_1/respawn').send({ + mode: 'codex', + resumeSessionId: 'thread-raw-respawn', + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + status: 'error', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + expect(codexLaunchPlanner.planCreateCalls).toEqual([]) + expect(resolveTarget).toHaveBeenCalledWith('pane_1') + expect(registryCreate).not.toHaveBeenCalled() + expect(attachPaneContent).not.toHaveBeenCalled() +}) + it('kills the created Codex respawn terminal without waiting for readiness when shutdown admission closes after adoption', async () => { const app = express() app.use(express.json()) @@ -310,13 +372,12 @@ it('kills the created Codex respawn terminal without waiting for readiness when const res = await request(app).post('/api/panes/pane_1/respawn').send({ mode: 'codex', - resumeSessionId: 'thread-respawn-shutdown', + sessionRef: { provider: 'codex', sessionId: 'thread-respawn-shutdown' }, }) expect(res.status).toBe(500) expect(res.body.message).toContain('Server is shutting down') expect(codexLaunchPlanner.sidecar.adoptCalls).toEqual([{ terminalId: 'term_respawn_shutdown', generation: 0 }]) - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toEqual([]) expect(registry.publishCodexSidecar).not.toHaveBeenCalled() expect(registry.killAndWait).toHaveBeenCalledWith('term_respawn_shutdown') expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) diff --git a/test/server/agent-run.test.ts b/test/server/agent-run.test.ts index ebd24fbb4..92583da8f 100644 --- a/test/server/agent-run.test.ts +++ b/test/server/agent-run.test.ts @@ -1,12 +1,10 @@ import { it, expect, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { EventEmitter } from 'node:events' import { createAgentApiRouter } from '../../server/agent-api/router' import { FakeCodexLaunchPlanner, DEFAULT_CODEX_REMOTE_WS_URL } from '../helpers/coding-cli/fake-codex-launch-planner.js' -const expectedFreshellToken = process.env.AUTH_TOKEN || '' -const expectedFreshellUrl = process.env.FRESHELL_URL || 'http://localhost:3001' - it('runs a command and returns captured output', async () => { let buffer = '' const registry = { @@ -14,7 +12,7 @@ it('runs a command and returns captured output', async () => { input: (_terminalId: string, data: string) => { const match = data.match(/__FRESHELL_DONE_[A-Za-z0-9_-]+__/) if (match) buffer = `done\n${match[0]}\n` - return true + return { status: 'written' } }, get: () => ({ buffer: { snapshot: () => buffer }, status: 'running' }), } @@ -38,7 +36,7 @@ it('allocates and passes an OpenCode control endpoint for /api/run in opencode m input: (_terminalId: string, data: string) => { const match = data.match(/__FRESHELL_DONE_[A-Za-z0-9_-]+__/) if (match) buffer = `done\n${match[0]}\n` - return true + return { status: 'written' } }, get: () => ({ buffer: { snapshot: () => buffer }, status: 'running' }), } @@ -67,13 +65,10 @@ it('allocates and passes an OpenCode control endpoint for /api/run in opencode m it('uses the Codex planner and marks fresh /api/run sessions as starts', async () => { const registry = { create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const app = express() app.use(express.json()) @@ -97,25 +92,14 @@ it('uses the Codex planner and marks fresh /api/run sessions as starts', async ( model: undefined, resumeSessionId: undefined, sandbox: undefined, - terminalId: expect.any(String), - env: expect.objectContaining({ - FRESHELL: '1', - FRESHELL_TERMINAL_ID: expect.any(String), - FRESHELL_TOKEN: expectedFreshellToken, - FRESHELL_URL: expectedFreshellUrl, - }), })) - expect(planCreate.env.FRESHELL_TERMINAL_ID).toBe(planCreate.terminalId) - expect(planCreate.env.FRESHELL_TAB_ID).toBe(createTab.mock.calls[0]?.[0]?.tabId) - expect(planCreate.env.FRESHELL_PANE_ID).toBe(createTab.mock.calls[0]?.[0]?.paneId) expect(registry.create).toHaveBeenCalledWith(expect.objectContaining({ mode: 'codex', - terminalId: planCreate.terminalId, - codexSidecar: codexLaunchPlanner.sidecar, resumeSessionId: undefined, sessionBindingReason: 'start', providerSettings: expect.objectContaining({ codexAppServer: expect.objectContaining({ + sidecar: codexLaunchPlanner.sidecar, wsUrl: DEFAULT_CODEX_REMOTE_WS_URL, }), }), @@ -123,12 +107,151 @@ it('uses the Codex planner and marks fresh /api/run sessions as starts', async ( expect(codexLaunchPlanner.sidecar.adoptCalls).toEqual([{ terminalId: 'term1', generation: 0 }]) }) +it('waits for fresh Codex /api/run restore identity before sending input', async () => { + const emitter = new EventEmitter() + let identityReady = false + const registry = Object.assign(emitter, { + create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), + get: vi.fn(() => ({ + status: 'running', + codexInputGate: { state: 'identity_pending' }, + })), + input: vi.fn(() => { + if (identityReady) return { status: 'written' } + queueMicrotask(() => { + identityReady = true + emitter.emit('terminal.codex.durability.updated', { terminalId: 'term1' }) + }) + return { status: 'blocked_codex_identity_pending', terminalId: 'term1' } + }), + killAndWait: vi.fn(async () => true), + }) + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { + createTab: () => ({ tabId: 't1', paneId: 'p1' }), + attachPaneContent: () => {}, + }, + registry, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/run').send({ command: 'echo done', mode: 'codex' }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('ok') + expect(res.body.message).toBe('command sent') + expect(registry.input).toHaveBeenCalledTimes(2) + expect(registry.killAndWait).not.toHaveBeenCalled() +}) + +it('does not buffer pending Codex /api/run input even if the terminal exits later', async () => { + const emitter = new EventEmitter() + const registry = Object.assign(emitter, { + create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), + get: vi.fn(() => ({ + status: 'running', + codexInputGate: { state: 'identity_pending' }, + })), + input: vi.fn(() => { + queueMicrotask(() => { + emitter.emit('terminal.exit', { terminalId: 'term1' }) + }) + return { status: 'blocked_codex_identity_pending', terminalId: 'term1' } + }), + killAndWait: vi.fn(async () => true), + }) + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { + createTab: () => ({ tabId: 't1', paneId: 'p1' }), + closeTab: vi.fn(), + attachPaneContent: () => {}, + }, + registry, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/run').send({ command: 'echo done', mode: 'codex' }) + + expect(res.status).toBe(500) + expect(res.body.message).toBe('Terminal is not running.') + expect(registry.input).toHaveBeenCalledTimes(1) + expect(registry.killAndWait).toHaveBeenCalledWith('term1') +}) + +it('fails when Codex restore identity is unavailable before /api/run input', async () => { + const registry = { + create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), + get: vi.fn(() => ({ + status: 'running', + codexDurability: { state: 'non_restorable' }, + })), + input: vi.fn(() => ({ status: 'blocked_codex_identity_unavailable', terminalId: 'term1' })), + killAndWait: vi.fn(async () => true), + } + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { + createTab: () => ({ tabId: 't1', paneId: 'p1' }), + closeTab: vi.fn(), + attachPaneContent: () => {}, + }, + registry, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/run').send({ command: 'echo done', mode: 'codex' }) + + expect(res.status).toBe(500) + expect(res.body.message).toBe('Codex restore identity could not be captured before input could be accepted.') + expect(registry.input).toHaveBeenCalledTimes(1) + expect(registry.killAndWait).toHaveBeenCalledWith('term1') +}) + +it('reports Codex recovery-pending input rejection for /api/run', async () => { + const registry = { + create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), + input: vi.fn(() => ({ status: 'blocked_codex_recovery_pending', terminalId: 'term1' })), + killAndWait: vi.fn(async () => true), + } + const codexLaunchPlanner = new FakeCodexLaunchPlanner() + + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { + createTab: () => ({ tabId: 't1', paneId: 'p1' }), + closeTab: vi.fn(), + attachPaneContent: () => {}, + }, + registry, + codexLaunchPlanner, + })) + + const res = await request(app).post('/api/run').send({ command: 'echo done', mode: 'codex' }) + + expect(res.status).toBe(500) + expect(res.body.message).toBe('Codex durable recovery is still in progress.') + expect(registry.input).toHaveBeenCalledTimes(1) + expect(registry.killAndWait).toHaveBeenCalledWith('term1') +}) + it('shuts down the pending Codex sidecar when /api/run fails after planning', async () => { const registry = { create: vi.fn(() => { throw new Error('spawn failed after planning') }), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() @@ -156,7 +279,7 @@ it('reports pending Codex sidecar shutdown failure when /api/run fails after pla create: vi.fn(() => { throw new Error('spawn failed after planning') }), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() codexLaunchPlanner.sidecar.shutdownError = new Error('verified sidecar teardown failed') @@ -184,7 +307,7 @@ it('reports pending Codex sidecar shutdown failure when /api/run fails after pla it('kills the created terminal and sidecar when /api/run fails after registry.create', async () => { const registry = { create: vi.fn(() => ({ terminalId: 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), killAndWait: vi.fn(async () => true), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() @@ -212,7 +335,7 @@ it('kills the created terminal and sidecar when /api/run fails after registry.cr it('reports created-terminal cleanup failure when /api/run fails after registry.create', async () => { const registry = { create: vi.fn(() => ({ terminalId: 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), killAndWait: vi.fn(async () => { throw new Error('terminal cleanup failed') }), @@ -243,14 +366,11 @@ it('reports created-terminal cleanup failure when /api/run fails after registry. it('retries initial Codex launch before starting a detached /api/run session', async () => { const registry = { create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() codexLaunchPlanner.failNext(2) - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const app = express() app.use(express.json()) @@ -280,14 +400,11 @@ it('retries initial Codex launch before starting a detached /api/run session', a it('fails detached /api/run without mutating layout when Codex launch retries are exhausted', async () => { const registry = { create: vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() codexLaunchPlanner.failNext(5) - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const app = express() app.use(express.json()) @@ -319,13 +436,10 @@ it('shuts down the planned Codex sidecar when /api/run terminal creation fails b create: vi.fn(() => { throw new Error('spawn failed') }), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const closeTab = vi.fn() const app = express() @@ -346,7 +460,7 @@ it('shuts down the planned Codex sidecar when /api/run terminal creation fails b expect(res.body).toEqual({ status: 'error', message: 'spawn failed' }) expect(codexLaunchPlanner.planCreateCalls).toHaveLength(1) expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) - expect(closeTab).toHaveBeenCalledWith(createTab.mock.calls[0]?.[0]?.tabId) + expect(closeTab).toHaveBeenCalledWith('t1') expect(registry.input).not.toHaveBeenCalled() }) @@ -354,7 +468,7 @@ it('rejects invalid Codex settings for /api/run before creating a tab', async () const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const registry = { create: vi.fn(() => ({ terminalId: 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() @@ -397,7 +511,7 @@ it('rejects Codex /api/run without planning when shutdown admission closes while const createTab = vi.fn(() => ({ tabId: 't1', paneId: 'p1' })) const registry = { create: vi.fn(() => ({ terminalId: 'term1' })), - input: vi.fn(() => true), + input: vi.fn(() => ({ status: 'written' })), killAndWait: vi.fn(async () => true), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() diff --git a/test/server/agent-send-keys.test.ts b/test/server/agent-send-keys.test.ts index 2438fa239..85ba71a99 100644 --- a/test/server/agent-send-keys.test.ts +++ b/test/server/agent-send-keys.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { it, expect, vi } from 'vitest' import express from 'express' import request from 'supertest' @@ -8,7 +9,7 @@ it('sends input to a pane terminal', async () => { app.use(express.json()) app.use('/api', createAgentApiRouter({ layoutStore: { resolvePaneToTerminal: () => 'term_1' }, - registry: { input: () => true }, + registry: { input: () => ({ status: 'written' }) }, })) const res = await request(app).post('/api/panes/p1/send-keys').send({ data: 'ls\r' }) @@ -16,7 +17,7 @@ it('sends input to a pane terminal', async () => { }) it('resolves tmux-style target to a pane before sending', async () => { - const input = vi.fn(() => true) + const input = vi.fn(() => ({ status: 'written' })) const app = express() app.use(express.json()) app.use('/api', createAgentApiRouter({ @@ -31,3 +32,60 @@ it('resolves tmux-style target to a pane before sending', async () => { expect(res.body.status).toBe('ok') expect(input).toHaveBeenCalledWith('term_2', 'C-c') }) + +it('rejects blocked Codex input instead of reporting success', async () => { + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { resolvePaneToTerminal: () => 'term_1' }, + registry: { + input: () => ({ + status: 'blocked_codex_identity_unavailable', + terminalId: 'term_1', + reason: 'candidate_persist_failed', + }), + }, + })) + + const res = await request(app).post('/api/panes/p1/send-keys').send({ data: 'ls\r' }) + + expect(res.status).toBe(409) + expect(res.body.status).toBe('error') + expect(res.body.message).toBe('Codex restore identity could not be captured before input could be accepted.') +}) + +it('waits for Codex identity capture before sending a seeded prompt when requested', async () => { + const events = new EventEmitter() + let identityReady = false + const input = vi.fn(() => ( + identityReady + ? { status: 'written' } + : { + status: 'blocked_codex_identity_pending', + terminalId: 'term_1', + } + )) + const app = express() + app.use(express.json()) + app.use('/api', createAgentApiRouter({ + layoutStore: { resolvePaneToTerminal: () => 'term_1' }, + registry: Object.assign(events, { input }), + })) + + const response = request(app) + .post('/api/panes/p1/send-keys') + .send({ data: 'build the thing\r', waitForCodexIdentity: true }) + const responsePromise = response.then((res) => res) + + await vi.waitFor(() => expect(input).toHaveBeenCalled()) + identityReady = true + events.emit('terminal.codex.durability.updated', { + terminalId: 'term_1', + durability: { state: 'captured_pre_turn' }, + }) + + const res = await responsePromise + expect(res.status).toBe(200) + expect(res.body.status).toBe('ok') + expect(input).toHaveBeenLastCalledWith('term_1', 'build the thing\r') +}) diff --git a/test/server/agent-tabs-write.test.ts b/test/server/agent-tabs-write.test.ts index 68e455c3f..d34a44685 100644 --- a/test/server/agent-tabs-write.test.ts +++ b/test/server/agent-tabs-write.test.ts @@ -3,9 +3,7 @@ import express from 'express' import request from 'supertest' import { createAgentApiRouter } from '../../server/agent-api/router' import { FakeCodexLaunchPlanner } from '../helpers/coding-cli/fake-codex-launch-planner.js' - -const expectedFreshellToken = process.env.AUTH_TOKEN || '' -const expectedFreshellUrl = process.env.FRESHELL_URL || 'http://localhost:3001' +import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../../server/coding-cli/codex-app-server/restore-decision.js' class FakeRegistry { create = vi.fn((opts?: { terminalId?: string }) => ({ terminalId: opts?.terminalId ?? 'term_1' })) @@ -134,10 +132,7 @@ describe('tab endpoints', () => { app.use(express.json()) const registry = new FakeRegistry() const codexLaunchPlanner = new FakeCodexLaunchPlanner() - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) const layoutStore = { createTab, attachPaneContent: vi.fn(), @@ -161,23 +156,12 @@ describe('tab endpoints', () => { model: undefined, resumeSessionId: undefined, sandbox: undefined, - terminalId: expect.any(String), - env: expect.objectContaining({ - FRESHELL: '1', - FRESHELL_TERMINAL_ID: expect.any(String), - FRESHELL_TOKEN: expectedFreshellToken, - FRESHELL_URL: expectedFreshellUrl, - }), })) - expect(planCreate.env.FRESHELL_TERMINAL_ID).toBe(planCreate.terminalId) - expect(planCreate.env.FRESHELL_TAB_ID).toBe(createTab.mock.calls[0]?.[0]?.tabId) - expect(planCreate.env.FRESHELL_PANE_ID).toBe(createTab.mock.calls[0]?.[0]?.paneId) expect(registry.create).toHaveBeenCalledWith(expect.objectContaining({ mode: 'codex', - terminalId: planCreate.terminalId, - codexSidecar: codexLaunchPlanner.sidecar, providerSettings: expect.objectContaining({ codexAppServer: expect.objectContaining({ + sidecar: codexLaunchPlanner.sidecar, wsUrl: expect.any(String), }), }), @@ -188,12 +172,12 @@ describe('tab endpoints', () => { const app = express() app.use(express.json()) const registry = new FakeRegistry() - const codexLaunchPlanner = new FakeCodexLaunchPlanner() + const codexLaunchPlanner = new FakeCodexLaunchPlanner({ + sessionId: 'thread-canonical', + remote: { wsUrl: 'ws://127.0.0.1:43123' }, + }) codexLaunchPlanner.failNext(2) - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) const layoutStore = { createTab, attachPaneContent: vi.fn(), @@ -253,10 +237,7 @@ describe('tab endpoints', () => { throw new Error('spawn failed') }) const codexLaunchPlanner = new FakeCodexLaunchPlanner() - const createTab = vi.fn((input: { tabId: string; paneId: string }) => ({ - tabId: input.tabId, - paneId: input.paneId, - })) + const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) const closeTab = vi.fn() const layoutStore = { createTab, @@ -276,7 +257,7 @@ describe('tab endpoints', () => { expect(res.body).toEqual({ status: 'error', message: 'spawn failed' }) expect(codexLaunchPlanner.planCreateCalls).toHaveLength(1) expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) - expect(closeTab).toHaveBeenCalledWith(createTab.mock.calls[0]?.[0]?.tabId) + expect(closeTab).toHaveBeenCalledWith('tab_1') }) it('rejects invalid Codex sandbox values with a 400 before spawning', async () => { @@ -428,21 +409,16 @@ describe('tab endpoints', () => { expect(layoutStore.attachPaneContent).not.toHaveBeenCalled() }) - it('kills the created Codex terminal when resume readiness returns after the PTY exited', async () => { + it('rejects raw Codex resume ids instead of fresh-creating tabs', async () => { const app = express() app.use(express.json()) - const terminal = { terminalId: 'term_exited_before_publish', status: 'running' } const registry = { - create: vi.fn(() => terminal), + create: vi.fn(), killAndWait: vi.fn(async () => true), } const codexLaunchPlanner = new FakeCodexLaunchPlanner() - vi.spyOn(codexLaunchPlanner.sidecar, 'waitForLoadedThread').mockImplementation(async (threadId, options) => { - codexLaunchPlanner.sidecar.waitForLoadedThreadCalls.push({ threadId, options }) - terminal.status = 'exited' - }) const layoutStore = { - createTab: () => ({ tabId: 'tab_1', paneId: 'pane_1' }), + createTab: vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })), attachPaneContent: vi.fn(), selectTab: () => ({}), renameTab: () => ({}), @@ -459,13 +435,63 @@ describe('tab endpoints', () => { resumeSessionId: 'thread-resume-exits', }) - expect(res.status).toBe(500) - expect(res.body.message).toContain('Codex terminal PTY exited before create completed') - expect(registry.killAndWait).toHaveBeenCalledWith('term_exited_before_publish') - expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + status: 'error', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + expect(codexLaunchPlanner.planCreateCalls).toEqual([]) + expect(registry.create).not.toHaveBeenCalled() + expect(registry.killAndWait).not.toHaveBeenCalled() + expect(layoutStore.createTab).not.toHaveBeenCalled() expect(layoutStore.attachPaneContent).not.toHaveBeenCalled() }) + it('uses canonical Codex sessionRef as the durable resume path', async () => { + const app = express() + app.use(express.json()) + const terminal = { terminalId: 'term_codex_canonical', status: 'running' } + const registry = { + create: vi.fn(() => terminal), + killAndWait: vi.fn(async () => true), + } + const codexLaunchPlanner = new FakeCodexLaunchPlanner({ + sessionId: 'thread-canonical', + remote: { wsUrl: 'ws://127.0.0.1:43123' }, + }) + const layoutStore = { + createTab: () => ({ tabId: 'tab_1', paneId: 'pane_1' }), + attachPaneContent: vi.fn(), + selectTab: () => ({}), + renameTab: () => ({}), + closeTab: () => ({}), + hasTab: () => true, + selectNextTab: () => ({ tabId: 'tab_1' }), + selectPrevTab: () => ({ tabId: 'tab_1' }), + } + app.use('/api', createAgentApiRouter({ layoutStore, registry, codexLaunchPlanner })) + + const sessionRef = { provider: 'codex', sessionId: 'thread-canonical' } + const res = await request(app).post('/api/tabs').send({ + mode: 'codex', + name: 'resume tab', + sessionRef, + }) + + expect(res.status).toBe(200) + expect(codexLaunchPlanner.planCreateCalls[0]).toEqual(expect.objectContaining({ + resumeSessionId: 'thread-canonical', + })) + expect(registry.create).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'codex', + resumeSessionId: 'thread-canonical', + })) + expect(layoutStore.attachPaneContent).toHaveBeenCalledWith('tab_1', 'pane_1', expect.objectContaining({ + sessionRef, + })) + expect(layoutStore.attachPaneContent.mock.calls[0]?.[2]).not.toHaveProperty('resumeSessionId') + }) + it('kills the created Codex terminal without waiting for readiness when shutdown admission closes after adoption', async () => { const app = express() app.use(express.json()) @@ -506,13 +532,12 @@ describe('tab endpoints', () => { const res = await request(app).post('/api/tabs').send({ mode: 'codex', name: 'resume tab', - resumeSessionId: 'thread-resume-shutdown', + sessionRef: { provider: 'codex', sessionId: 'thread-resume-shutdown' }, }) expect(res.status).toBe(500) expect(res.body.message).toContain('Server is shutting down') expect(codexLaunchPlanner.sidecar.adoptCalls).toEqual([{ terminalId: 'term_shutdown_after_adopt', generation: 0 }]) - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toEqual([]) expect(registry.publishCodexSidecar).not.toHaveBeenCalled() expect(registry.killAndWait).toHaveBeenCalledWith('term_shutdown_after_adopt') expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) diff --git a/test/server/codex-activity-exact-subset.test.ts b/test/server/codex-activity-exact-subset.test.ts index f8c20c903..757daeb5d 100644 --- a/test/server/codex-activity-exact-subset.test.ts +++ b/test/server/codex-activity-exact-subset.test.ts @@ -292,6 +292,7 @@ describe('Codex activity exact subset wiring', () => { mode: 'codex', cwd: '/repo/project', }) + registry.releaseCodexInputGateForTest(canonical.terminalId) registry.get(canonical.terminalId)!.resumeSessionId = 'codex-session-repair-pending' registry.emit('terminal.session.bound', { terminalId: canonical.terminalId, @@ -353,6 +354,7 @@ describe('Codex activity exact subset wiring', () => { }) const term = registry.create({ mode: 'codex', cwd: '/repo/project' }) + registry.releaseCodexInputGateForTest(term.terminalId) registry.setResumeSessionId(term.terminalId, 'codex-session-2') vi.setSystemTime(2_000) diff --git a/test/server/session-association-broadcast.test.ts b/test/server/session-association-broadcast.test.ts index 583526bd3..69eb5a773 100644 --- a/test/server/session-association-broadcast.test.ts +++ b/test/server/session-association-broadcast.test.ts @@ -1,13 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { broadcastTerminalSessionAssociation } from '../../server/session-association-broadcast' +import { + broadcastTerminalSessionAssociation, + createTerminalSessionAssociationPublisher, +} from '../../server/session-association-broadcast' import { recordSessionLifecycleEvent } from '../../server/session-observability' +import { TerminalMetadataService } from '../../server/terminal-metadata-service' vi.mock('../../server/session-observability.js', () => ({ recordSessionLifecycleEvent: vi.fn(), })) const SESSION_ID_ONE = '550e8400-e29b-41d4-a716-446655440000' -const SESSION_ID_TWO = '6f1c2b3a-4d5e-6f70-8a9b-0c1d2e3f4a5b' +const SESSION_ID_TWO = '6f1c2b3a-4d5e-4f70-8a9b-0c1d2e3f4a5b' function createHarness() { const metaUpsert = { @@ -28,6 +32,34 @@ function createHarness() { } } +function createPublisherHarness() { + let now = 100 + const terminalMetadata = new TerminalMetadataService({ + now: () => ++now, + git: { + resolveCheckoutRoot: async (cwd) => cwd, + resolveRepoRoot: async (cwd) => cwd, + resolveBranchAndDirty: async () => ({}), + }, + }) + const wsHandler = { + broadcast: vi.fn(), + } + const broadcastTerminalMetaUpserts = vi.fn() + const publisher = createTerminalSessionAssociationPublisher({ + wsHandler, + terminalMetadata, + broadcastTerminalMetaUpserts, + }) + + return { + publisher, + terminalMetadata, + wsHandler, + broadcastTerminalMetaUpserts, + } +} + describe('broadcastTerminalSessionAssociation', () => { beforeEach(() => { vi.mocked(recordSessionLifecycleEvent).mockClear() @@ -122,4 +154,169 @@ describe('broadcastTerminalSessionAssociation', () => { source: 'opencode_controller', }) }) + + it('dedupes repeated publications for the same provider/session/terminal pair', async () => { + const { + publisher, + terminalMetadata, + wsHandler, + broadcastTerminalMetaUpserts, + } = createPublisherHarness() + + await terminalMetadata.seedFromTerminal({ + terminalId: 'term-claude', + mode: 'claude', + cwd: '/tmp/project', + }) + + expect(publisher.publish({ + provider: 'claude', + terminalId: 'term-claude', + sessionId: SESSION_ID_ONE, + source: 'claude_new_session', + })).toBe('published') + + expect(publisher.publish({ + provider: 'claude', + terminalId: 'term-claude', + sessionId: SESSION_ID_ONE, + source: 'indexer_update', + })).toBe('deduped') + + expect(wsHandler.broadcast).toHaveBeenCalledTimes(1) + expect(broadcastTerminalMetaUpserts).toHaveBeenCalledTimes(1) + expect(terminalMetadata.get('term-claude')).toMatchObject({ + provider: 'claude', + sessionId: SESSION_ID_ONE, + }) + }) + + it('publishes a pending association after terminal metadata is seeded', async () => { + const { + publisher, + terminalMetadata, + wsHandler, + broadcastTerminalMetaUpserts, + } = createPublisherHarness() + + expect(publisher.publish({ + provider: 'opencode', + terminalId: 'term-opencode', + sessionId: 'opencode-session-1', + source: 'opencode_controller', + })).toBe('pendingMetadata') + + expect(wsHandler.broadcast).not.toHaveBeenCalled() + + await expect(publisher.seedFromTerminal({ + terminalId: 'term-opencode', + mode: 'opencode', + cwd: '/tmp/project', + })).resolves.toBe('published') + + expect(wsHandler.broadcast).toHaveBeenCalledWith({ + type: 'terminal.session.associated', + terminalId: 'term-opencode', + sessionRef: { + provider: 'opencode', + sessionId: 'opencode-session-1', + }, + }) + expect(broadcastTerminalMetaUpserts).toHaveBeenCalledTimes(1) + expect(terminalMetadata.get('term-opencode')).toMatchObject({ + provider: 'opencode', + sessionId: 'opencode-session-1', + }) + + expect(publisher.publish({ + provider: 'opencode', + terminalId: 'term-opencode', + sessionId: 'opencode-session-1', + source: 'indexer_update', + })).toBe('deduped') + expect(wsHandler.broadcast).toHaveBeenCalledTimes(1) + }) + + it('clears stale active metadata when a durable session rebinds to another terminal', async () => { + const { + publisher, + terminalMetadata, + wsHandler, + broadcastTerminalMetaUpserts, + } = createPublisherHarness() + + await terminalMetadata.seedFromTerminal({ + terminalId: 'term-old', + mode: 'claude', + cwd: '/tmp/project', + }) + await terminalMetadata.seedFromTerminal({ + terminalId: 'term-new', + mode: 'claude', + cwd: '/tmp/project', + }) + + expect(publisher.publish({ + provider: 'claude', + terminalId: 'term-old', + sessionId: SESSION_ID_ONE, + source: 'claude_new_session', + })).toBe('published') + + expect(publisher.publish({ + provider: 'claude', + terminalId: 'term-new', + sessionId: SESSION_ID_ONE, + source: 'indexer_update', + })).toBe('rebound') + + expect(wsHandler.broadcast).toHaveBeenCalledTimes(2) + expect(terminalMetadata.get('term-old')).toMatchObject({ + terminalId: 'term-old', + cwd: '/tmp/project', + }) + expect(terminalMetadata.get('term-old')?.provider).toBeUndefined() + expect(terminalMetadata.get('term-old')?.sessionId).toBeUndefined() + expect(terminalMetadata.get('term-new')).toMatchObject({ + provider: 'claude', + sessionId: SESSION_ID_ONE, + }) + expect(broadcastTerminalMetaUpserts).toHaveBeenLastCalledWith(expect.arrayContaining([ + expect.objectContaining({ terminalId: 'term-old', provider: undefined, sessionId: undefined }), + expect.objectContaining({ terminalId: 'term-new', provider: 'claude', sessionId: SESSION_ID_ONE }), + ])) + }) + + it('deletes pending association metadata when a terminal exits before seed', async () => { + const { + publisher, + wsHandler, + broadcastTerminalMetaUpserts, + } = createPublisherHarness() + + expect(publisher.publish({ + provider: 'opencode', + terminalId: 'term-exit-before-seed', + sessionId: 'opencode-session-1', + source: 'opencode_controller', + })).toBe('pendingMetadata') + + publisher.forgetTerminal('term-exit-before-seed') + + await expect(publisher.seedFromTerminal({ + terminalId: 'term-exit-before-seed', + mode: 'opencode', + cwd: '/tmp/reused', + })).resolves.toBe('seeded') + + expect(wsHandler.broadcast).not.toHaveBeenCalled() + expect(broadcastTerminalMetaUpserts).toHaveBeenCalledTimes(1) + expect(broadcastTerminalMetaUpserts).toHaveBeenCalledWith([ + expect.objectContaining({ + terminalId: 'term-exit-before-seed', + provider: 'opencode', + sessionId: undefined, + }), + ]) + }) }) diff --git a/test/server/session-association.test.ts b/test/server/session-association.test.ts index 1db22992c..9305a132e 100644 --- a/test/server/session-association.test.ts +++ b/test/server/session-association.test.ts @@ -1,3 +1,6 @@ +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { beforeEach, describe, it, expect, vi } from 'vitest' import { TerminalRegistry } from '../../server/terminal-registry' import { CodingCliSessionIndexer } from '../../server/coding-cli/session-indexer' @@ -6,6 +9,8 @@ import { SessionAssociationCoordinator } from '../../server/session-association- import { TerminalMetadataService } from '../../server/terminal-metadata-service' import { collectAppliedSessionAssociations } from '../../server/session-association-updates' import { recordSessionLifecycleEvent } from '../../server/session-observability' +import { CodexDurabilityStore } from '../../server/coding-cli/codex-app-server/durability-store' +import { CODEX_DURABILITY_SCHEMA_VERSION } from '../../shared/codex-durability' vi.mock('node-pty', () => ({ spawn: vi.fn(() => ({ @@ -27,13 +32,13 @@ vi.mock('../../server/session-observability.js', () => ({ })) const SESSION_ID_ONE = '550e8400-e29b-41d4-a716-446655440000' -const SESSION_ID_TWO = '6f1c2b3a-4d5e-6f70-8a9b-0c1d2e3f4a5b' +const SESSION_ID_TWO = '6f1c2b3a-4d5e-4f70-8a9b-0c1d2e3f4a5b' const SESSION_ID_THREE = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' const SESSION_ID_FOUR = '2c1a2a5a-3f9f-4b5e-9b39-7d7e0c9a4b10' const SESSION_ID_FIVE = '3a0b2c9f-1e2d-4f6a-8f3a-4b8a9d7c1e20' -const SESSION_ID_SIX = '4b1c3d2e-5f6a-7b8c-9d0e-1f2a3b4c5d6e' -const SESSION_ID_SEVEN = '5c2d4e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f' -const SESSION_ID_EIGHT = '6d3e5f7a-8b9c-0d1e-2f3a-4b5c6d7e8f90' +const SESSION_ID_SIX = '4b1c3d2e-5f6a-4b8c-9d0e-1f2a3b4c5d6e' +const SESSION_ID_SEVEN = '5c2d4e6f-7a8b-4c0d-9e2f-3a4b5c6d7e8f' +const SESSION_ID_EIGHT = '6d3e5f7a-8b9c-4d1e-af3a-4b5c6d7e8f90' function createMetadataService() { let now = 1_000 @@ -188,39 +193,60 @@ describe('SessionAssociationCoordinator integration', () => { registry.shutdown() }) - it('records a lifecycle event when the Codex sidecar reports durable identity', () => { - let onDurableSession: ((sessionId: string) => void) | undefined - const sidecar = { - attachTerminal: vi.fn((callbacks: { onDurableSession: (sessionId: string) => void }) => { - onDurableSession = callbacks.onDurableSession - }), - shutdown: vi.fn(async () => undefined), - } - const registry = new TerminalRegistry() - const terminal = registry.create({ - mode: 'codex', - cwd: '/home/user/project', - codexSidecar: sidecar, + it('records a lifecycle event when Codex durable identity is proven from the rollout file', async () => { + const testDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-proof-')) + const durabilityDir = path.join(testDir, 'durability') + const rolloutPath = path.join(testDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + `${JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-1' } })}\n`, + 'utf8', + ) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), }) - onDurableSession?.('codex-thread-1') - onDurableSession?.('codex-thread-1') - - const durableObservationCalls = vi.mocked(recordSessionLifecycleEvent).mock.calls.filter(([event]) => - event.kind === 'codex_durable_session_observed' - ) - expect(durableObservationCalls).toEqual([[ - { - kind: 'codex_durable_session_observed', - provider: 'codex', - terminalId: terminal.terminalId, - sessionId: 'codex-thread-1', - generation: 1, - source: 'sidecar', - }, - ]]) + try { + const terminal = registry.create({ + mode: 'codex', + cwd: '/home/user/project', + codexSidecar: { + shutdown: vi.fn(async () => undefined), + } as any, + }) + const record = registry.get(terminal.terminalId) + expect(record).toBeTruthy() + record!.codexDurability = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'codex-thread-1', + rolloutPath, + source: 'thread_start_response', + capturedAt: 1_000, + }, + } - registry.shutdown() + await (registry as any).runCodexDurabilityProof(terminal.terminalId, 'test') + + const durableObservationCalls = vi.mocked(recordSessionLifecycleEvent).mock.calls.filter(([event]) => + event.kind === 'codex_durable_session_observed' + ) + expect(durableObservationCalls).toEqual([[ + { + kind: 'codex_durable_session_observed', + provider: 'codex', + terminalId: terminal.terminalId, + sessionId: 'codex-thread-1', + generation: 0, + source: 'sidecar', + }, + ]]) + } finally { + registry.shutdown() + await fsp.rm(testDir, { recursive: true, force: true }) + } }) }) @@ -855,7 +881,7 @@ describe('Session-Terminal Association via onUpdate', () => { registry.shutdown() }) - it('associates opencode sessions when resume is supported', () => { + it('skips opencode sessions in onUpdate ownership pass', () => { const registry = new TerminalRegistry() const broadcasts: any[] = [] @@ -876,9 +902,8 @@ describe('Session-Terminal Association via onUpdate', () => { }], }], broadcasts) - expect(broadcasts).toHaveLength(1) - expect(broadcasts[0].terminalId).toBe(term.terminalId) - expect(registry.get(term.terminalId)?.resumeSessionId).toBe('opencode-session-123') + expect(broadcasts).toHaveLength(0) + expect(registry.get(term.terminalId)?.resumeSessionId).toBeUndefined() registry.shutdown() }) diff --git a/test/server/terminals-api.test.ts b/test/server/terminals-api.test.ts index 7fbf07b43..998621318 100644 --- a/test/server/terminals-api.test.ts +++ b/test/server/terminals-api.test.ts @@ -261,6 +261,24 @@ describe('Terminals API', () => { expect(secondTerminal.mode).toBe('claude') }) + it('includes the last emitted terminal line', async () => { + const terminal = registry.addTerminal({ + terminalId: 'term_last_line', + title: 'Repo push', + mode: 'shell', + }) + terminal.buffer.append('first line\nsecond line\nvagrant@gf-software-factory-vm:/workspace/project$ ') + + const response = await request(app) + .get('/api/terminals') + .set('x-auth-token', AUTH_TOKEN) + .expect(200) + + const item = response.body.find((t: any) => t.terminalId === 'term_last_line') + expect(item.lastLine).toBe('second line') + expect(item.last_line).toBe('second line') + }) + it('applies title override from config', async () => { registry.addTerminal({ terminalId: 'term_with_override', diff --git a/test/server/ws-handshake-snapshot.test.ts b/test/server/ws-handshake-snapshot.test.ts index 1289420fa..9698428c3 100644 --- a/test/server/ws-handshake-snapshot.test.ts +++ b/test/server/ws-handshake-snapshot.test.ts @@ -360,6 +360,113 @@ describe('ws handshake snapshot', () => { } }) + it('does not synthesize Codex sessionRef from resumeSessionId until durability is proven', async () => { + registry.setTerminals([ + { + terminalId: 'term-codex-unproven', + title: 'Codex CLI', + mode: 'codex', + resumeSessionId: 'thread-unproven', + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-unproven', + rolloutPath: '/home/user/.codex/sessions/unproven.jsonl', + source: 'thread_start_response', + capturedAt: 1, + }, + }, + createdAt: 1, + lastActivityAt: 2, + status: 'running', + }, + { + terminalId: 'term-codex-durable', + title: 'Codex CLI', + mode: 'codex', + resumeSessionId: 'thread-durable', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-durable', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-durable', + rolloutPath: '/home/user/.codex/sessions/durable.jsonl', + source: 'thread_start_response', + capturedAt: 1, + }, + turnCompletedAt: 2, + }, + createdAt: 3, + lastActivityAt: 4, + status: 'running', + }, + { + terminalId: 'term-codex-mismatch', + title: 'Codex CLI', + mode: 'codex', + resumeSessionId: 'thread-legacy', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-proof', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-proof', + rolloutPath: '/home/user/.codex/sessions/mismatch.jsonl', + source: 'thread_start_response', + capturedAt: 1, + }, + turnCompletedAt: 2, + }, + createdAt: 5, + lastActivityAt: 6, + status: 'running', + }, + { + terminalId: 'term-claude-legacy', + title: 'Claude CLI', + mode: 'claude', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440000', + createdAt: 7, + lastActivityAt: 8, + status: 'running', + }, + ]) + + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + + const inventoryPromise = waitForMessage(ws, (m) => m.type === 'terminal.inventory', 10_000) + await waitForReady(ws, 10_000) + + const inventory = await inventoryPromise + const byId = new Map(inventory.terminals.map((terminal: any) => [terminal.terminalId, terminal])) + expect(byId.get('term-codex-unproven')).not.toHaveProperty('sessionRef') + expect(byId.get('term-codex-unproven')).not.toHaveProperty('resumeSessionId') + expect(byId.get('term-codex-durable')).toMatchObject({ + sessionRef: { + provider: 'codex', + sessionId: 'thread-durable', + }, + }) + expect(byId.get('term-codex-mismatch')).not.toHaveProperty('sessionRef') + expect(byId.get('term-claude-legacy')).toMatchObject({ + sessionRef: { + provider: 'claude', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + } finally { + await closeWs(ws) + } + }) + it('keeps inventory lifetime status separate from runtime recovery status', async () => { registry.setTerminals([ { diff --git a/test/server/ws-opencode-activity.test.ts b/test/server/ws-opencode-activity.test.ts index 171d4fed1..dc56f3723 100644 --- a/test/server/ws-opencode-activity.test.ts +++ b/test/server/ws-opencode-activity.test.ts @@ -207,4 +207,38 @@ describe('ws opencode activity protocol', () => { authenticated.close() unauthenticated.close() }) + + it('broadcasts terminal.turn.complete only to authenticated sockets', async () => { + const authenticated = new WebSocket(`ws://127.0.0.1:${port}/ws`) + const unauthenticated = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + await Promise.all([ + new Promise<void>((resolve) => authenticated.on('open', () => resolve())), + new Promise<void>((resolve) => unauthenticated.on('open', () => resolve())), + ]) + + authenticated.send(JSON.stringify({ type: 'hello', token: 'opencode-activity-token', protocolVersion: WS_PROTOCOL_VERSION })) + await waitForMessage(authenticated, (msg) => msg.type === 'ready') + + wsHandler.broadcastTerminalTurnComplete({ + terminalId: 'term-opencode-1', + provider: 'opencode', + sessionId: 'session-opencode-1', + at: 1234, + }) + + const completed = await waitForMessage(authenticated, (msg) => msg.type === 'terminal.turn.complete') + expect(completed).toEqual({ + type: 'terminal.turn.complete', + terminalId: 'term-opencode-1', + provider: 'opencode', + sessionId: 'session-opencode-1', + at: 1234, + }) + + await expect(expectNoMatchingMessage(unauthenticated, (msg) => msg.type === 'terminal.turn.complete')).resolves.toBeUndefined() + + authenticated.close() + unauthenticated.close() + }) }) diff --git a/test/server/ws-protocol.test.ts b/test/server/ws-protocol.test.ts index 7af2b1e1a..e1941c27a 100644 --- a/test/server/ws-protocol.test.ts +++ b/test/server/ws-protocol.test.ts @@ -171,9 +171,9 @@ class FakeRegistry { input(terminalId: string, data: string) { const rec = this.records.get(terminalId) - if (!rec) return false + if (!rec) return { status: 'no_terminal' } this.inputCalls.push({ terminalId, data }) - return true + return { status: 'written' } } resize(terminalId: string, cols: number, rows: number) { @@ -229,6 +229,16 @@ class FakeRegistry { return undefined } + async readCodexDurabilityRecordForRestoreLocator() { + return null + } + + async readCodexDurabilityForRestoreLocator() { + return null + } + + async deleteCodexDurabilityStoreRecord() {} + repairLegacySessionOwners() { return { repaired: false, clearedTerminalIds: [] } } @@ -340,8 +350,6 @@ describe('ws protocol', () => { codexLaunchPlanner.sidecar.shutdownCalls = 0 codexLaunchPlanner.sidecar.shutdownStarted = false codexLaunchPlanner.sidecar.shutdownError = null - codexLaunchPlanner.sidecar.waitForLoadedThreadCalls = [] - codexLaunchPlanner.sidecar.waitForLoadedThreadError = null }) afterAll(async () => { @@ -505,7 +513,7 @@ describe('ws protocol', () => { resumeSessionId: undefined, sandbox: 'workspace-write', }]) - expect(registry.createCalls[0]?.resumeSessionId).toBe('thread-new-1') + expect(registry.createCalls[0]?.resumeSessionId).toBeUndefined() expect(registry.createCalls[0]?.providerSettings).toEqual({ codexAppServer: expect.objectContaining({ wsUrl: DEFAULT_CODEX_REMOTE_WS_URL, @@ -788,56 +796,7 @@ describe('ws protocol', () => { expect(localRegistry.records.size).toBe(0) }) - it('aborts in-flight Codex resume terminal.create when shutdown starts during loaded-list readiness', async () => { - const localServer = http.createServer((_req, res) => { - res.statusCode = 404 - res.end() - }) - const localRegistry = new FakeRegistry() - const sidecar = new FakeCodexLaunchSidecar() - const readiness = deferred() - const originalWaitForLoadedThread = sidecar.waitForLoadedThread.bind(sidecar) - const localPlanner = new FakeCodexLaunchPlanner({ - sessionId: 'thread-during-readiness', - remote: { wsUrl: DEFAULT_CODEX_REMOTE_WS_URL }, - sidecar, - }) - const localHandler = new WsHandler(localServer, localRegistry as any, { codexLaunchPlanner: localPlanner }) - vi.spyOn(sidecar, 'waitForLoadedThread').mockImplementation(async (threadId, options) => { - await originalWaitForLoadedThread(threadId, options) - localHandler.close() - await readiness.promise - }) - const sent: any[] = [] - const ws = createOpenFakeWs('shutdown-during-readiness', sent) - const state = createAuthenticatedState() - - const message = (localHandler as any).onMessage( - ws, - state, - Buffer.from(JSON.stringify({ - type: 'terminal.create', - requestId: 'shutdown-during-readiness', - mode: 'codex', - resumeSessionId: 'thread-during-readiness', - })), - ) - await vi.waitFor(() => expect(sidecar.waitForLoadedThreadCalls).toHaveLength(1)) - readiness.resolve() - await message - - expect(sent).toContainEqual(expect.objectContaining({ - type: 'error', - requestId: 'shutdown-during-readiness', - })) - expect(sidecar.adoptCalls).toHaveLength(1) - expect(localRegistry.publishCalls).toEqual([]) - expect(localRegistry.killCalls).toHaveLength(1) - expect(sidecar.shutdownCalls).toBe(1) - expect(localRegistry.records.size).toBe(0) - }) - - it('waits for candidate-local loaded-thread readiness before reporting Codex resume create success', async () => { + it('reports Codex resume create success without loaded-thread readiness polling', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) await new Promise<void>((resolve) => ws.on('open', () => resolve())) ws.send(JSON.stringify({ type: 'hello', token: 'testtoken-testtoken', protocolVersion: WS_PROTOCOL_VERSION })) @@ -848,7 +807,7 @@ describe('ws protocol', () => { type: 'terminal.create', requestId, mode: 'codex', - resumeSessionId: 'thread-resume-1', + sessionRef: { provider: 'codex', sessionId: 'thread-resume-1' }, })) const created = await waitForMessage( ws, @@ -859,48 +818,15 @@ describe('ws protocol', () => { expect(codexLaunchPlanner.planCreateCalls[0]).toEqual(expect.objectContaining({ resumeSessionId: 'thread-resume-1', })) - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toEqual([{ - threadId: 'thread-resume-1', - options: undefined, - }]) expect(registry.publishCalls).toEqual([created.terminalId]) await closeWebSocket(ws) }) - it('kills the created terminal and sidecar when Codex resume loaded-list readiness fails', async () => { - codexLaunchPlanner.sidecar.waitForLoadedThreadError = new Error('resume thread never loaded') - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) - await new Promise<void>((resolve) => ws.on('open', () => resolve())) - ws.send(JSON.stringify({ type: 'hello', token: 'testtoken-testtoken', protocolVersion: WS_PROTOCOL_VERSION })) - await waitForMessage(ws, (msg) => msg.type === 'ready', 5000) - - const requestId = 'codex-resume-loaded-list-fails' - ws.send(JSON.stringify({ - type: 'terminal.create', - requestId, - mode: 'codex', - resumeSessionId: 'thread-missing', - })) - const error = await waitForMessage( - ws, - (msg) => msg.type === 'error' && msg.requestId === requestId, - 5000, - ) - - expect(error.message).toContain('resume thread never loaded') - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toHaveLength(1) - expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) - expect(registry.killCalls).toHaveLength(1) - expect(registry.records.size).toBe(0) - - await closeWebSocket(ws) - }) - it('kills the created terminal and sidecar when the Codex resume PTY exits before publication', async () => { - const originalWaitForLoadedThread = codexLaunchPlanner.sidecar.waitForLoadedThread.bind(codexLaunchPlanner.sidecar) - const waitSpy = vi.spyOn(codexLaunchPlanner.sidecar, 'waitForLoadedThread').mockImplementation(async (threadId, options) => { - await originalWaitForLoadedThread(threadId, options) + const originalAdopt = codexLaunchPlanner.sidecar.adopt.bind(codexLaunchPlanner.sidecar) + const adoptSpy = vi.spyOn(codexLaunchPlanner.sidecar, 'adopt').mockImplementation(async (input) => { + await originalAdopt(input) const terminalId = codexLaunchPlanner.sidecar.adoptCalls[0]?.terminalId const record = terminalId ? registry.get(terminalId) : null if (record) record.status = 'exited' @@ -916,7 +842,7 @@ describe('ws protocol', () => { type: 'terminal.create', requestId, mode: 'codex', - resumeSessionId: 'thread-resume-exits', + sessionRef: { provider: 'codex', sessionId: 'thread-resume-exits' }, })) const error = await waitForMessage( ws, @@ -925,12 +851,11 @@ describe('ws protocol', () => { ) expect(error.message).toContain('Codex terminal PTY exited before create completed') - expect(codexLaunchPlanner.sidecar.waitForLoadedThreadCalls).toHaveLength(1) expect(codexLaunchPlanner.sidecar.shutdownCalls).toBe(1) expect(registry.killCalls).toHaveLength(1) expect(registry.records.size).toBe(0) } finally { - waitSpy.mockRestore() + adoptSpy.mockRestore() await closeWebSocket(ws) } }) @@ -1225,6 +1150,31 @@ describe('ws protocol', () => { await close() }) + it('terminal.input reports Codex identity capture timeout as blocked input', async () => { + const { ws, close } = await createAuthenticatedConnection() + + const terminalId = await createTerminal(ws, 'create-for-codex-timeout-input') + const originalInput = registry.input.bind(registry) + registry.input = vi.fn(() => ({ + status: 'blocked_codex_identity_capture_timeout', + terminalId, + })) as any + + try { + ws.send(JSON.stringify({ type: 'terminal.input', terminalId, data: 'test' })) + + const blocked = await waitForMessage(ws, (msg) => msg.type === 'terminal.input.blocked') + expect(blocked).toEqual({ + type: 'terminal.input.blocked', + terminalId, + reason: 'codex_identity_capture_timeout', + }) + } finally { + registry.input = originalInput as any + await close() + } + }) + it('terminal.resize changes terminal dimensions', async () => { const { ws, close } = await createAuthenticatedConnection() diff --git a/test/server/ws-session-observability.test.ts b/test/server/ws-session-observability.test.ts index f6cde257d..d3bd641c1 100644 --- a/test/server/ws-session-observability.test.ts +++ b/test/server/ws-session-observability.test.ts @@ -305,8 +305,87 @@ describe('websocket session observability', () => { } }) + it('records restore unavailable lifecycle events without creating a terminal', async () => { + const ws = await connectReady(port) + + try { + const errorPromise = waitForMessage( + ws, + (msg) => msg.type === 'error' && msg.requestId === 'req-restore-missing', + ) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId: 'req-restore-missing', + tabId: 'tab-restore', + paneId: 'pane-restore', + cwd: '/home/user/project', + mode: 'opencode', + shell: 'system', + restore: true, + })) + + await errorPromise + + await waitForLifecycleEvent({ + kind: 'restore_unavailable', + requestId: 'req-restore-missing', + connectionId: 'conn-1', + tabId: 'tab-restore', + paneId: 'pane-restore', + mode: 'opencode', + reason: 'missing_canonical_session_id', + restoreRequested: true, + hasSessionRef: false, + }) + expect(registry.create).not.toHaveBeenCalled() + } finally { + await closeWebSocket(ws) + } + }) + + it('records explicit fresh restore-fallback lifecycle events', async () => { + const ws = await connectReady(port) + + try { + const createdPromise = waitForMessage( + ws, + (msg) => msg.type === 'terminal.created' && msg.requestId === 'req-fresh-fallback', + ) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId: 'req-fresh-fallback', + tabId: 'tab-fresh', + paneId: 'pane-fresh', + cwd: '/home/user/project', + mode: 'shell', + shell: 'system', + recoveryIntent: 'fresh_after_restore_unavailable', + })) + + await createdPromise + + await waitForLifecycleEvent({ + kind: 'restore_unavailable_fresh_fallback', + requestId: 'req-fresh-fallback', + connectionId: 'conn-1', + tabId: 'tab-fresh', + paneId: 'pane-fresh', + mode: 'shell', + reason: 'fresh_after_restore_unavailable', + restoreRequested: false, + treatedAsFresh: true, + hasSessionRef: false, + }) + expect(registry.create).toHaveBeenCalledTimes(1) + } finally { + await closeWebSocket(ws) + } + }) + it('records stale terminal input without logging input data', async () => { - registry.input.mockReturnValue(false) + registry.input.mockReturnValue({ status: 'no_terminal' }) const ws = await connectReady(port) try { diff --git a/test/server/ws-tabs-registry.test.ts b/test/server/ws-tabs-registry.test.ts index dd877fe7a..eddeee2fa 100644 --- a/test/server/ws-tabs-registry.test.ts +++ b/test/server/ws-tabs-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import http from 'http' import WebSocket from 'ws' import os from 'os' @@ -58,8 +58,6 @@ function makeRecord(overrides: Record<string, unknown>) { return { tabKey: 'device-1:tab-1', tabId: 'tab-1', - deviceId: 'device-1', - deviceLabel: 'danlaptop', tabName: 'freshell', status: 'open', revision: 1, @@ -91,13 +89,7 @@ describe('ws tabs registry protocol', () => { let wsHandler: any let tempDir: string - beforeAll(async () => { - process.env.NODE_ENV = 'test' - process.env.AUTH_TOKEN = 'tabs-sync-token' - - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-tabs-registry-')) - const tabsStore = createTabsRegistryStore(tempDir, { now: () => NOW }) - + async function startServer(options: { tabsRegistryStore?: any } = {}) { const { WsHandler } = await import('../../server/ws-handler') server = http.createServer((_req, res) => { res.statusCode = 404 @@ -106,29 +98,57 @@ describe('ws tabs registry protocol', () => { wsHandler = new WsHandler( server, new FakeRegistry() as any, - { tabsRegistryStore: tabsStore }, + options, ) port = await listen(server) + } + + async function connect(): Promise<WebSocket> { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + ws.send(JSON.stringify({ type: 'hello', token: 'tabs-sync-token', protocolVersion: WS_PROTOCOL_VERSION })) + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws + } + + beforeEach(async () => { + process.env.NODE_ENV = 'test' + process.env.AUTH_TOKEN = 'tabs-sync-token' + delete process.env.MAX_REGULAR_WS_MESSAGE_BYTES + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-tabs-registry-')) }) - afterAll(async () => { + afterEach(async () => { wsHandler?.close?.() - await new Promise<void>((resolve) => server.close(() => resolve())) + if (server?.listening) { + await new Promise<void>((resolve) => server.close(() => resolve())) + } await fs.rm(tempDir, { recursive: true, force: true }) + delete process.env.MAX_REGULAR_WS_MESSAGE_BYTES }) - it('accepts tabs.sync.push and returns tabs.sync.snapshot (default 24h)', async () => { + it('uses protocol version 5 and rejects version 4 clients with reload-required mismatch', async () => { + expect(WS_PROTOCOL_VERSION).toBe(5) + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) await new Promise<void>((resolve) => ws.on('open', () => resolve())) - ws.send(JSON.stringify({ type: 'hello', token: 'tabs-sync-token', protocolVersion: WS_PROTOCOL_VERSION })) - const ready = await waitForMessage(ws, (msg) => msg.type === 'ready') - expect(typeof ready.serverInstanceId).toBe('string') - expect(ready.serverInstanceId.length).toBeGreaterThan(0) + ws.send(JSON.stringify({ type: 'hello', token: 'tabs-sync-token', protocolVersion: 4 })) + const error = await waitForMessage(ws, (msg) => msg.type === 'error' && msg.code === 'PROTOCOL_MISMATCH') + expect(error.message).toMatch(/expected protocol version 5/i) + expect(error.message).toMatch(/reload/i) + ws.close() + }) + + it('accepts v5 push/query, returns same-device/devices, and rejects invalid retention', async () => { + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() ws.send(JSON.stringify({ type: 'tabs.sync.push', deviceId: 'local-device', - deviceLabel: 'danlaptop', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, records: [ makeRecord({ tabKey: 'local:open-1', @@ -137,16 +157,35 @@ describe('ws tabs registry protocol', () => { }), ], })) + const localAck = await waitForMessage(ws, (msg) => msg.type === 'tabs.sync.ack') + expect(localAck).toMatchObject({ accepted: true, openRecords: 1, closedRecords: 0 }) + + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'local:open-2', + tabId: 'open-2', + status: 'open', + }), + ], + })) await waitForMessage(ws, (msg) => msg.type === 'tabs.sync.ack') ws.send(JSON.stringify({ type: 'tabs.sync.push', deviceId: 'remote-device', - deviceLabel: 'danshapiromain', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, records: [ makeRecord({ tabKey: 'remote:open-1', - tabId: 'open-2', + tabId: 'open-3', status: 'open', }), makeRecord({ @@ -156,43 +195,247 @@ describe('ws tabs registry protocol', () => { updatedAt: NOW - 2 * 60 * 60 * 1000, closedAt: NOW - 2 * 60 * 60 * 1000, }), - makeRecord({ - tabKey: 'remote:closed-old', - tabId: 'closed-old', - status: 'closed', - updatedAt: NOW - 5 * 24 * 60 * 60 * 1000, - closedAt: NOW - 5 * 24 * 60 * 60 * 1000, - }), ], })) - await waitForMessage(ws, (msg) => msg.type === 'tabs.sync.ack') + const remoteAck = await waitForMessage(ws, (msg) => msg.type === 'tabs.sync.ack') + expect(remoteAck).toMatchObject({ accepted: true, openRecords: 1, closedRecords: 1 }) ws.send(JSON.stringify({ type: 'tabs.sync.query', requestId: 'snapshot-1', deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, })) const snapshot = await waitForMessage( ws, (msg) => msg.type === 'tabs.sync.snapshot' && msg.requestId === 'snapshot-1', ) - expect(snapshot.data.localOpen.some((record: any) => record.tabKey === 'local:open-1')).toBe(true) - expect(snapshot.data.remoteOpen.some((record: any) => record.tabKey === 'remote:open-1')).toBe(true) - expect(snapshot.data.closed.some((record: any) => record.tabKey === 'remote:closed-recent')).toBe(true) - expect(snapshot.data.closed.some((record: any) => record.tabKey === 'remote:closed-old')).toBe(false) + expect(snapshot.data.localOpen.map((record: any) => record.tabKey)).toEqual(['local:open-1']) + expect(snapshot.data.sameDeviceOpen.map((record: any) => record.tabKey)).toEqual(['local:open-2']) + expect(snapshot.data.sameDeviceOpen[0].clientInstanceId).toBe('window-b') + expect(snapshot.data.remoteOpen.map((record: any) => record.tabKey)).toEqual(['remote:open-1']) + expect(snapshot.data.remoteOpen[0].clientInstanceId).toBe('remote-window') + expect(snapshot.data.closed.map((record: any) => record.tabKey)).toEqual(['remote:closed-recent']) + expect(snapshot.data.devices.map((device: any) => device.deviceId).sort()).toEqual(['local-device', 'remote-device']) + + ws.send(JSON.stringify({ + type: 'tabs.sync.query', + requestId: 'bad-retention', + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 31, + })) + const error = await waitForMessage(ws, (msg) => msg.type === 'error' && msg.requestId === 'bad-retention') + expect(error.message).toMatch(/closedTabRetentionDays/i) + + ws.send(JSON.stringify({ + type: 'tabs.sync.query', + requestId: 'missing-client-instance', + deviceId: 'local-device', + closedTabRetentionDays: 30, + })) + const missingClientError = await waitForMessage( + ws, + (msg) => msg.type === 'error' && msg.requestId === 'missing-client-instance', + ) + expect(missingClientError.message).toMatch(/clientInstanceId/i) + ws.close() + }) + + it('requires clientInstanceId/snapshotRevision and retires only that client snapshot', async () => { + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + deviceId: 'local-device', + deviceLabel: 'local', + records: [], + })) + const invalid = await waitForMessage(ws, (msg) => msg.type === 'error' && msg.code === 'INVALID_MESSAGE') + expect(invalid.message).toMatch(/clientInstanceId|snapshotRevision/) + + for (const clientInstanceId of ['window-a', 'window-b']) { + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId, + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: `local:${clientInstanceId}`, + tabId: clientInstanceId, + status: 'open', + }), + ], + })) + await waitForMessage(ws, (msg) => msg.type === 'tabs.sync.ack') + } + + ws.send(JSON.stringify({ + type: 'tabs.sync.client.retire', + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 2, + })) + + let snapshot: any + await vi.waitFor(async () => { + const requestId = `snapshot-after-retire-${Date.now()}-${Math.random()}` + ws.send(JSON.stringify({ + type: 'tabs.sync.query', + requestId, + deviceId: 'local-device', + clientInstanceId: 'window-b', + closedTabRetentionDays: 30, + })) + snapshot = await waitForMessage( + ws, + (msg) => msg.type === 'tabs.sync.snapshot' && msg.requestId === requestId, + ) + expect(snapshot.data.sameDeviceOpen).toHaveLength(0) + }) + expect(snapshot.data.localOpen.map((record: any) => record.tabKey)).toEqual(['local:window-b']) + expect(snapshot.data.sameDeviceOpen).toHaveLength(0) + ws.close() + }) + + it('returns a clear query error when the registry is unavailable', async () => { + await startServer() + const ws = await connect() ws.send(JSON.stringify({ type: 'tabs.sync.query', - requestId: 'snapshot-2', + requestId: 'missing-store', deviceId: 'local-device', - rangeDays: 30, + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, })) - const longRange = await waitForMessage( + const error = await waitForMessage(ws, (msg) => msg.type === 'error' && msg.requestId === 'missing-store') + expect(error.message).toMatch(/tabs registry unavailable/i) + ws.close() + }) + + it('returns clear tabs sync errors for store validation failures instead of crashing', async () => { + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: Array.from({ length: 501 }, (_, i) => makeRecord({ + tabKey: `local:${i}`, + tabId: `tab-${i}`, + status: 'open', + })), + })) + + const error = await waitForMessage(ws, (msg) => msg.type === 'error') + expect(error).toMatchObject({ code: 'INVALID_MESSAGE' }) + expect(error.message).toMatch(/at most 500 records/i) + expect(ws.readyState).not.toBe(WebSocket.CLOSED) + ws.close() + }) + + it('serves migrated legacy tabs once websocket startup accepts queries', async () => { + const legacyPath = path.join(tempDir, 'tabs-registry.jsonl') + await fs.writeFile(legacyPath, `${JSON.stringify(makeRecord({ + tabKey: 'remote:legacy-open', + tabId: 'legacy-open', + serverInstanceId: 'legacy-srv', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'open', + }))}\n`, 'utf-8') + const migratedStore = await createTabsRegistryStore(tempDir, { now: () => NOW }) + await startServer({ tabsRegistryStore: migratedStore }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'tabs.sync.query', + requestId: 'legacy-after-startup', + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + })) + const snapshot = await waitForMessage( ws, - (msg) => msg.type === 'tabs.sync.snapshot' && msg.requestId === 'snapshot-2', + (msg) => msg.type === 'tabs.sync.snapshot' && msg.requestId === 'legacy-after-startup', ) - expect(longRange.data.closed.some((record: any) => record.tabKey === 'remote:closed-old')).toBe(true) + + expect(snapshot.data.remoteOpen.map((record: any) => record.tabKey)).toEqual(['remote:legacy-open']) + await expect(fs.stat(legacyPath)).rejects.toMatchObject({ code: 'ENOENT' }) + ws.close() + }) + + it('rejects oversized regular websocket messages before normal parsing with a clear error', async () => { + process.env.MAX_REGULAR_WS_MESSAGE_BYTES = '256' + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'local:large', + tabId: 'large', + panes: [{ paneId: 'pane-1', kind: 'terminal', payload: { text: 'x'.repeat(512) } }], + }), + ], + })) + + const error = await waitForMessage(ws, (msg) => msg.type === 'error') + expect(error.message).toMatch(/message.*256 bytes/i) + ws.close() + delete process.env.MAX_REGULAR_WS_MESSAGE_BYTES + }) + + it('does not allow oversized regular websocket messages to bypass the cap with screenshot text in another field', async () => { + process.env.MAX_REGULAR_WS_MESSAGE_BYTES = '256' + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'tabs.sync.push', + junk: '"type":"ui.screenshot.result"' + 'x'.repeat(512), + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [], + })) + + const error = await waitForMessage(ws, (msg) => msg.type === 'error') + expect(error.message).toMatch(/message.*256 bytes/i) + ws.close() + delete process.env.MAX_REGULAR_WS_MESSAGE_BYTES + }) + + it('does not allow screenshot-shaped websocket envelopes to carry oversized unknown fields', async () => { + process.env.MAX_REGULAR_WS_MESSAGE_BYTES = '256' + await startServer({ tabsRegistryStore: await createTabsRegistryStore(tempDir, { now: () => NOW }) }) + const ws = await connect() + + ws.send(JSON.stringify({ + type: 'ui.screenshot.result', + requestId: 'unknown-junk', + ok: false, + junk: 'x'.repeat(512), + })) + + const error = await waitForMessage(ws, (msg) => msg.type === 'error') + expect(error.message).toMatch(/message.*256 bytes|unknown.*field/i) ws.close() + delete process.env.MAX_REGULAR_WS_MESSAGE_BYTES }) }) diff --git a/test/server/ws-terminal-create-reuse-running-codex.test.ts b/test/server/ws-terminal-create-reuse-running-codex.test.ts index 809d26cbc..c351b2e75 100644 --- a/test/server/ws-terminal-create-reuse-running-codex.test.ts +++ b/test/server/ws-terminal-create-reuse-running-codex.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import http from 'http' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { EventEmitter } from 'node:events' import WebSocket from 'ws' import { WS_PROTOCOL_VERSION } from '../../shared/ws-protocol' import { FakeCodexLaunchPlanner, DEFAULT_CODEX_REMOTE_WS_URL } from '../helpers/coding-cli/fake-codex-launch-planner.js' @@ -169,16 +173,28 @@ type FakeTerminal = { cols: number rows: number resumeSessionId?: string + codexDurability?: any clients: Set<WebSocket> } -class FakeRegistry { +class FakeRegistry extends EventEmitter { records: FakeTerminal[] attachCalls: Array<{ terminalId: string; opts?: any }> = [] createCalls: any[] = [] repairCalls: Array<{ mode: string; sessionId: string }> = [] + candidatePersistedAcks: any[] = [] + promoteCalls: Array<{ terminalId: string; durableThreadId: string }> = [] + deletedDurabilityRecords: Array<{ terminalId: string; reason: string }> = [] + durabilityRestoreRecords: Array<{ + terminalId: string + tabId?: string + paneId?: string + serverInstanceId?: string + durability: any + }> = [] constructor(terminalIds: string[]) { + super() const createdAt = Date.now() this.records = terminalIds.map((terminalId, idx) => ({ terminalId, @@ -226,10 +242,78 @@ class FakeRegistry { }) } + bindSession(terminalId: string, mode: string, sessionId: string) { + const record = this.findById(terminalId) + if (!record || mode !== 'codex') return { ok: false, reason: 'terminal_missing' } + record.resumeSessionId = sessionId + return { ok: true, terminalId, sessionId } + } + + async promoteCodexDurabilityFromCreateProof(terminalId: string, durableThreadId: string) { + this.promoteCalls.push({ terminalId, durableThreadId }) + const bound = this.bindSession(terminalId, 'codex', durableThreadId) + if (!bound.ok) return bound + const record = this.findById(terminalId) + if (record) { + record.codexDurability = { + schemaVersion: 1, + state: 'durable', + durableThreadId, + } + this.emit('terminal.codex.durability.updated', { + terminalId, + durability: record.codexDurability, + }) + } + return bound + } + findRunningClaudeTerminalBySession(sessionId: string) { return this.findRunningTerminalBySession('claude', sessionId) } + findRunningCodexTerminalByCandidate(candidateThreadId: string, rolloutPath: string) { + return this.records.find((record) => ( + record.status === 'running' + && record.codexDurability?.candidate?.candidateThreadId === candidateThreadId + && record.codexDurability?.candidate?.rolloutPath === rolloutPath + )) + } + + async readCodexDurabilityRecordForRestoreLocator(locator: { + terminalId?: string + tabId?: string + paneId?: string + serverInstanceId?: string + }) { + if (locator.terminalId) { + const record = this.durabilityRestoreRecords.find((candidate) => candidate.terminalId === locator.terminalId) + return record ? { terminalId: record.terminalId, durability: record.durability } : undefined + } + if (!locator.tabId || !locator.paneId) return undefined + const matches = this.durabilityRestoreRecords.filter((record) => ( + record.tabId === locator.tabId + && record.paneId === locator.paneId + && (!locator.serverInstanceId || record.serverInstanceId === locator.serverInstanceId) + )) + if (matches.length > 1) throw new Error('ambiguous restore locator') + return matches[0] ? { terminalId: matches[0].terminalId, durability: matches[0].durability } : undefined + } + + async readCodexDurabilityForRestoreLocator(locator: { + terminalId?: string + tabId?: string + paneId?: string + serverInstanceId?: string + }) { + return (await this.readCodexDurabilityRecordForRestoreLocator(locator))?.durability + } + + async deleteCodexDurabilityStoreRecord(terminalId: string, reason: string) { + this.deletedDurabilityRecords.push({ terminalId, reason }) + this.durabilityRestoreRecords = this.durabilityRestoreRecords.filter((record) => record.terminalId !== terminalId) + } + attach(terminalId: string, ws: WebSocket, opts?: any) { this.attachCalls.push({ terminalId, opts }) const record = this.findById(terminalId) @@ -259,6 +343,11 @@ class FakeRegistry { } list() { return [] } + + acknowledgeCodexCandidatePersisted(input: any) { + this.candidatePersistedAcks.push(input) + return 'accepted' + } } describe('terminal.create reuse running codex terminal', () => { @@ -289,6 +378,10 @@ describe('terminal.create reuse running codex terminal', () => { registry.attachCalls = [] registry.createCalls = [] registry.repairCalls = [] + registry.candidatePersistedAcks = [] + registry.promoteCalls = [] + registry.deletedDurabilityRecords = [] + registry.durabilityRestoreRecords = [] }, HOOK_TIMEOUT_MS) afterEach(async () => { @@ -400,12 +493,75 @@ describe('terminal.create reuse running codex terminal', () => { } }) - it('existingId branch returns created only and requires explicit attach', async () => { + it('rejects raw Codex resume ids on restore instead of creating a fresh terminal', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) try { await new Promise<void>((resolve) => ws.on('open', () => resolve())) await waitForReady(ws) + const requestId = 'codex-raw-resume-restore' + const errorPromise = waitForMessage(ws, (m) => m.type === 'error' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + resumeSessionId: 'thread-raw-restore', + })) + + const error = await errorPromise + expect(error).toMatchObject({ + type: 'error', + code: 'INVALID_MESSAGE', + message: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + requestId, + }) + expect(codexLaunchPlanner.planCreateCalls).toHaveLength(0) + expect(registry.createCalls).toHaveLength(0) + } finally { + await closeWebSocket(ws) + } + }) + + it.each([ + ['omitted', undefined], + ['false', false], + ] as const)('rejects raw Codex resume ids when restore is %s', async (_label, restore) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = `codex-raw-resume-create-${_label}` + const errorPromise = waitForMessage(ws, (m) => m.type === 'error' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + ...(restore === undefined ? {} : { restore }), + resumeSessionId: 'thread-raw-create', + })) + + const error = await errorPromise + expect(error).toMatchObject({ + type: 'error', + code: 'INVALID_MESSAGE', + message: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + requestId, + }) + expect(codexLaunchPlanner.planCreateCalls).toHaveLength(0) + expect(registry.createCalls).toHaveLength(0) + } finally { + await closeWebSocket(ws) + } + }) + + it('existingId branch returns created only and requires explicit attach', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const helloReady = await waitForReady(ws) + const firstCreatedPromise = waitForMessage( ws, (m) => m.type === 'terminal.created' && m.requestId === 'reuse-existingId-split', @@ -504,18 +660,9 @@ describe('terminal.create reuse running codex terminal', () => { model: undefined, sandbox: undefined, approvalPolicy: undefined, - terminalId: expect.any(String), - env: expect.objectContaining({ - FRESHELL: '1', - FRESHELL_TERMINAL_ID: expect.any(String), - FRESHELL_TOKEN: 'testtoken-testtoken', - FRESHELL_URL: 'http://localhost:3001', - }), })) - expect(planCreate.env.FRESHELL_TERMINAL_ID).toBe(planCreate.terminalId) expect(registry.createCalls).toHaveLength(1) expect(registry.createCalls[0]).toMatchObject({ - terminalId: planCreate.terminalId, mode: 'codex', cwd: '/repo/worktree', resumeSessionId: undefined, @@ -530,6 +677,497 @@ describe('terminal.create reuse running codex terminal', () => { } }) + it('proof-reads captured Codex durability and resumes only after proof succeeds', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-proof-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-proved"}}\n', + 'utf8', + ) + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-proved-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + const associatedPromise = waitForMessage(ws, (m) => ( + m.type === 'terminal.session.associated' + && m.sessionRef?.provider === 'codex' + && m.sessionRef?.sessionId === 'thread-proved' + )) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-proved', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + }, + })) + + const created = await createdPromise + const associated = await associatedPromise + expect(created).not.toHaveProperty('effectiveResumeSessionId') + expect(associated.terminalId).toBe(created.terminalId) + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: 'thread-proved', + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: 'thread-proved', + }) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('proof-reads server-stored Codex durability when the client has not persisted candidate state', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-store-proof-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-store-proved"}}\n', + 'utf8', + ) + registry.durabilityRestoreRecords.push({ + terminalId: 'old-store-terminal', + tabId: 'tab-bridge', + paneId: 'pane-bridge', + durability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-store-proved', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + }, + }) + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-store-proved-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + tabId: 'tab-bridge', + paneId: 'pane-bridge', + })) + + await createdPromise + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: 'thread-store-proved', + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: 'thread-store-proved', + }) + expect(registry.deletedDurabilityRecords).toEqual([{ + terminalId: 'old-store-terminal', + reason: 'restore_proof_succeeded_created_replacement', + }]) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('does not use server-stored Codex durability for non-restore fresh creates', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-store-fresh-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-store-stale"}}\n', + 'utf8', + ) + registry.durabilityRestoreRecords.push({ + terminalId: 'old-store-terminal', + tabId: 'tab-fresh', + paneId: 'pane-fresh', + durability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-store-stale', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + }, + }) + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-fresh-ignores-store-record' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + tabId: 'tab-fresh', + paneId: 'pane-fresh', + })) + + await createdPromise + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: undefined, + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: undefined, + }) + expect(registry.deletedDurabilityRecords).toEqual([]) + expect(registry.durabilityRestoreRecords).toHaveLength(1) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('fresh-creates with restore failure when server-stored Codex durability cannot be proved', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-store-proof-missing-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'missing.jsonl') + registry.durabilityRestoreRecords.push({ + terminalId: 'old-store-terminal', + tabId: 'tab-bridge', + paneId: 'pane-bridge', + durability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-store-missing', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + }, + }) + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-store-unproved-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + tabId: 'tab-bridge', + paneId: 'pane-bridge', + })) + + const created = await createdPromise + expect(created).toMatchObject({ + type: 'terminal.created', + requestId, + clearCodexDurability: true, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }) + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: undefined, + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: undefined, + }) + expect(registry.deletedDurabilityRecords).toEqual([{ + terminalId: 'old-store-terminal', + reason: 'restore_proof_failed_fresh_create', + }]) + expect(registry.durabilityRestoreRecords).toHaveLength(0) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('proof-reads a same-server live Codex candidate before reattaching it', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-proof-live-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-live-proved"}}\n', + 'utf8', + ) + registry.records[0].resumeSessionId = undefined + registry.records[0].codexDurability = { + schemaVersion: 1, + state: 'durability_unproven_after_completion', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-live-proved', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + turnCompletedAt: Date.now(), + } + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const helloReady = await waitForReady(ws) + + const requestId = 'codex-proved-live-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + const associatedPromise = waitForMessage(ws, (m) => ( + m.type === 'terminal.session.associated' + && m.terminalId === 'term-codex-existing' + && m.sessionRef?.sessionId === 'thread-live-proved' + )) + const durabilityPromise = waitForMessage(ws, (m) => ( + m.type === 'terminal.codex.durability.updated' + && m.terminalId === 'term-codex-existing' + && m.durability?.state === 'durable' + && m.durability?.durableThreadId === 'thread-live-proved' + )) + const terminalsChangedPromise = waitForMessage(ws, (m) => m.type === 'terminals.changed') + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + liveTerminal: { + terminalId: 'term-codex-existing', + serverInstanceId: helloReady.serverInstanceId, + }, + codexDurability: registry.records[0].codexDurability, + })) + + const created = await createdPromise + await associatedPromise + await durabilityPromise + await terminalsChangedPromise + expect(created.terminalId).toBe('term-codex-existing') + expect(registry.records[0].resumeSessionId).toBe('thread-live-proved') + expect(registry.records[0].codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-live-proved', + }) + expect(registry.promoteCalls).toEqual([{ + terminalId: 'term-codex-existing', + durableThreadId: 'thread-live-proved', + }]) + expect(codexLaunchPlanner.planCreateCalls).toHaveLength(0) + expect(registry.createCalls).toHaveLength(0) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('does not promote a stale same-server live Codex handle when its candidate differs', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-proof-live-mismatch-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'rollout.jsonl') + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-proved-mismatch"}}\n', + 'utf8', + ) + registry.records[0].resumeSessionId = undefined + registry.records[0].codexDurability = { + schemaVersion: 1, + state: 'durability_unproven_after_completion', + candidate: { + provider: 'codex', + candidateThreadId: 'different-live-thread', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + turnCompletedAt: Date.now(), + } + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const helloReady = await waitForReady(ws) + + const requestId = 'codex-proved-live-mismatch-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + liveTerminal: { + terminalId: 'term-codex-existing', + serverInstanceId: helloReady.serverInstanceId, + }, + codexDurability: { + schemaVersion: 1, + state: 'durability_unproven_after_completion', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-proved-mismatch', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + turnCompletedAt: Date.now(), + }, + })) + + await createdPromise + expect(registry.promoteCalls).toEqual([]) + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: 'thread-proved-mismatch', + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: 'thread-proved-mismatch', + }) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('does not resume a captured Codex candidate when proof fails', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-proof-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'missing.jsonl') + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-unproved-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + codexDurability: { + schemaVersion: 1, + state: 'durability_unproven_after_completion', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-missing', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + turnCompletedAt: Date.now(), + }, + })) + + const created = await createdPromise + expect(created).toMatchObject({ + type: 'terminal.created', + requestId, + clearCodexDurability: true, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }) + expect(codexLaunchPlanner.planCreateCalls[0]).toMatchObject({ + resumeSessionId: undefined, + }) + expect(registry.createCalls[0]).toMatchObject({ + mode: 'codex', + resumeSessionId: undefined, + }) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('attaches exact live Codex candidate when captured proof fails and live terminal exists', async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-ws-codex-proof-')) + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + const rolloutPath = path.join(tempDir, 'missing-live.jsonl') + registry.records[0].codexDurability = { + schemaVersion: 1, + state: 'durability_unproven_after_completion', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-live-unproved', + rolloutPath, + source: 'thread_started_notification', + capturedAt: Date.now(), + }, + turnCompletedAt: Date.now(), + } + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'codex-unproved-live-reopen' + const createdPromise = waitForMessage(ws, (m) => m.type === 'terminal.created' && m.requestId === requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + restore: true, + codexDurability: registry.records[0].codexDurability, + })) + + const created = await createdPromise + expect(created.terminalId).toBe('term-codex-existing') + expect(codexLaunchPlanner.planCreateCalls).toHaveLength(0) + expect(registry.createCalls).toHaveLength(0) + } finally { + await closeWebSocket(ws) + await fsp.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('accepts Codex candidate persisted acknowledgements through the dynamic websocket schema', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const messagesPromise = collectMessages(ws, 75) + ws.send(JSON.stringify({ + type: 'terminal.codex.candidate.persisted', + terminalId: 'term-codex-existing', + candidateThreadId: 'thread-ack', + rolloutPath: '/tmp/codex/thread-ack.jsonl', + capturedAt: Date.now(), + })) + + const messages = await messagesPromise + expect(registry.candidatePersistedAcks).toHaveLength(1) + expect(registry.candidatePersistedAcks[0]).toMatchObject({ + terminalId: 'term-codex-existing', + candidateThreadId: 'thread-ack', + rolloutPath: '/tmp/codex/thread-ack.jsonl', + }) + expect(messages.some((message) => message.type === 'error' && message.code === 'INVALID_MESSAGE')).toBe(false) + } finally { + await closeWebSocket(ws) + } + }) + it('reuses canonical owner and repairs duplicate session records before reuse', async () => { const { WsHandler } = await import('../../server/ws-handler') const dupeServer = http.createServer((_req, res) => { res.statusCode = 404; res.end() }) diff --git a/test/server/ws-terminal-create-session-repair.test.ts b/test/server/ws-terminal-create-session-repair.test.ts index 37cd7888c..46444d2cc 100644 --- a/test/server/ws-terminal-create-session-repair.test.ts +++ b/test/server/ws-terminal-create-session-repair.test.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'events' import type { SessionScanResult } from '../../server/session-scanner/types.js' import { configStore } from '../../server/config-store.js' import { WS_PROTOCOL_VERSION } from '../../shared/ws-protocol' +import { FakeCodexLaunchPlanner } from '../helpers/coding-cli/fake-codex-launch-planner.js' const HOOK_TIMEOUT_MS = 30000 const VALID_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' @@ -12,6 +13,13 @@ const REPAIR_WAIT_MS = 75 const REPAIR_STAGGER_MS = 20 const DUPLICATE_SETTLE_MS = 100 const DISCONNECT_SETTLE_MS = 150 +const loggerMock = vi.hoisted(() => ({ + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + child: vi.fn(), +})) const DEFAULT_CONFIG_SNAPSHOT = vi.hoisted(() => ({ version: 1, settings: {}, @@ -19,10 +27,17 @@ const DEFAULT_CONFIG_SNAPSHOT = vi.hoisted(() => ({ terminalOverrides: {}, projectColors: {}, })) +loggerMock.child.mockReturnValue(loggerMock) + +vi.mock('../../server/logger', () => ({ + logger: loggerMock, + sessionLifecycleLogger: loggerMock, +})) vi.mock('../../server/config-store', () => ({ configStore: { snapshot: vi.fn().mockResolvedValue(DEFAULT_CONFIG_SNAPSHOT), + pushRecentDirectory: vi.fn().mockResolvedValue(undefined), }, })) @@ -129,6 +144,19 @@ function closeWebSocket(ws: WebSocket, timeoutMs = 500): Promise<void> { }) } +async function waitForLoggerWarn( + predicate: (call: [Record<string, any>, string?]) => boolean, + timeoutMs = 1000, +): Promise<[Record<string, any>, string?]> { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const call = loggerMock.warn.mock.calls.find((entry) => predicate(entry as [Record<string, any>, string?])) + if (call) return call as [Record<string, any>, string?] + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error('Timed out waiting for logger warn call') +} + class FakeBuffer { private s = '' append(t: string) { this.s += t } @@ -140,6 +168,9 @@ class FakeRegistry { lastCreateOpts: any = null createCallCount = 0 forceAttachFailure = false + killAndWait = vi.fn(async (terminalId: string) => { + this.records.delete(terminalId) + }) create(opts: any) { this.lastCreateOpts = opts @@ -293,7 +324,10 @@ describe('terminal.create session repair wait', () => { sessionRepairService = new FakeSessionRepairService() registry = new FakeRegistry() - new WsHandler(server, registry as any, { sessionRepairService: sessionRepairService as any }) + new WsHandler(server, registry as any, { + sessionRepairService: sessionRepairService as any, + codexLaunchPlanner: new FakeCodexLaunchPlanner(), + }) const info = await listen(server) port = info.port @@ -308,6 +342,10 @@ describe('terminal.create session repair wait', () => { registry.lastCreateOpts = null registry.createCallCount = 0 registry.forceAttachFailure = false + loggerMock.warn.mockClear() + loggerMock.info.mockClear() + loggerMock.debug.mockClear() + loggerMock.error.mockClear() }, HOOK_TIMEOUT_MS) afterEach(async () => { @@ -860,6 +898,94 @@ describe('terminal.create session repair wait', () => { } }) + it('logs websocket errors server-side before sending them to the client', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const responsePromise = waitForMessage( + ws, + (m) => m.type === 'error' && m.code === 'INVALID_TERMINAL_ID', + ) + ws.send(JSON.stringify({ + type: 'terminal.attach', + terminalId: 'missing-term', + attachRequestId: 'attach-missing-term', + intent: 'viewport_hydrate', + cols: 80, + rows: 24, + })) + + await responsePromise + + expect(loggerMock.warn).toHaveBeenCalledWith(expect.objectContaining({ + event: 'ws_send_error', + code: 'INVALID_TERMINAL_ID', + messageClass: 'terminal_not_running', + terminalId: 'missing-term', + requestId: 'attach-missing-term', + }), 'ws_send_error') + expect(JSON.stringify(loggerMock.warn.mock.calls)).not.toContain('Terminal not running') + } finally { + await closeWebSocket(ws) + } + }) + + it('summarizes repeated websocket errors without logging request text', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + for (const requestId of ['attach-loop-1', 'attach-loop-2', 'attach-loop-3']) { + const responsePromise = waitForMessage( + ws, + (m) => m.type === 'error' && m.code === 'INVALID_TERMINAL_ID', + ) + ws.send(JSON.stringify({ + type: 'terminal.attach', + terminalId: 'missing-term', + attachRequestId: requestId, + intent: 'viewport_hydrate', + cols: 80, + rows: 24, + })) + await responsePromise + } + + const firstLogCalls = loggerMock.warn.mock.calls.filter((call) => ( + call[0]?.event === 'ws_send_error' + && call[0]?.code === 'INVALID_TERMINAL_ID' + && call[0]?.terminalId === 'missing-term' + )) + expect(firstLogCalls).toHaveLength(1) + } finally { + await closeWebSocket(ws) + } + + const [summary] = await waitForLoggerWarn((call) => ( + call[0]?.event === 'ws_send_error_suppressed_summary' + && call[0]?.code === 'INVALID_TERMINAL_ID' + && call[0]?.terminalId === 'missing-term' + )) + expect(summary).toMatchObject({ + event: 'ws_send_error_suppressed_summary', + reason: 'connection_close', + code: 'INVALID_TERMINAL_ID', + messageClass: 'terminal_not_running', + terminalId: 'missing-term', + suppressedCount: 2, + totalCount: 3, + firstRequestId: 'attach-loop-1', + lastRequestId: 'attach-loop-3', + }) + const serializedCalls = JSON.stringify(loggerMock.warn.mock.calls) + expect(serializedCalls).not.toContain('Terminal not running') + }) + it('does not skip resume for healthy sessions with inline-progress resume issue', async () => { // Simulate: getResult returns a cached result with resumeIssue sessionRepairService.result = { @@ -914,6 +1040,147 @@ describe('terminal.create session repair wait', () => { } }) + it('uses sessionRef as canonical restore identity over legacy resumeSessionId', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'resume-session-ref-wins' + const createdPromise = waitForCreated(ws, requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'claude', + resumeSessionId: 'legacy_wrong_session', + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + })) + + await createdPromise + + expect(registry.lastCreateOpts?.resumeSessionId).toBe(VALID_SESSION_ID) + expect(sessionRepairService.waitForSessionCalls).toContain(VALID_SESSION_ID) + expect(sessionRepairService.waitForSessionCalls).not.toContain('legacy_wrong_session') + } finally { + await closeWebSocket(ws) + } + }) + + it('reuses a same-server live terminal handle without creating a duplicate terminal', async () => { + const existing = registry.create({ + mode: 'claude', + shell: 'system', + resumeSessionId: VALID_SESSION_ID, + }) + registry.lastCreateOpts = null + registry.createCallCount = 0 + + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const ready = await waitForReady(ws) + + const requestId = 'resume-live-terminal' + const createdPromise = waitForCreated(ws, requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'claude', + restore: true, + liveTerminal: { + terminalId: existing.terminalId, + serverInstanceId: ready.serverInstanceId, + }, + })) + + const created = await createdPromise + + expect(created.terminalId).toBe(existing.terminalId) + expect(registry.createCallCount).toBe(0) + expect(registry.records.size).toBe(1) + } finally { + await closeWebSocket(ws) + } + }) + + it('falls through from a stale live terminal handle to durable session restore', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const ready = await waitForReady(ws) + + const requestId = 'resume-stale-live-with-session-ref' + const createdPromise = waitForCreated(ws, requestId) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'claude', + restore: true, + liveTerminal: { + terminalId: 'missing-live-terminal', + serverInstanceId: ready.serverInstanceId, + }, + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + })) + + await createdPromise + + expect(registry.lastCreateOpts?.resumeSessionId).toBe(VALID_SESSION_ID) + expect(registry.createCallCount).toBe(1) + } finally { + await closeWebSocket(ws) + } + }) + + it('rejects stale live terminal restore when no durable session identity exists', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + const ready = await waitForReady(ws) + + const requestId = 'resume-stale-live-missing-session-ref' + const responsePromise = waitForMessage( + ws, + (m) => ( + m.requestId === requestId + && (m.type === 'terminal.created' || m.type === 'error') + ), + ) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'claude', + restore: true, + liveTerminal: { + terminalId: 'missing-live-terminal', + serverInstanceId: ready.serverInstanceId, + }, + })) + + const response = await responsePromise + + expect(response).toMatchObject({ + type: 'error', + code: 'RESTORE_UNAVAILABLE', + }) + expect(registry.createCallCount).toBe(0) + expect(registry.records.size).toBe(0) + } finally { + await closeWebSocket(ws) + } + }) + it('fails closed when Claude restore is requested with a non-canonical sessionRef', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) @@ -989,4 +1256,111 @@ describe('terminal.create session repair wait', () => { await closeWebSocket(ws) } }) + + it.each(['codex', 'claude', 'opencode'] as const)('rejects %s restore when durable identity is missing', async (mode) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = `req-${mode}-missing-ref` + const responsePromise = waitForMessage( + ws, + (m) => m.requestId === requestId && (m.type === 'terminal.created' || m.type === 'error'), + ) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode, + restore: true, + cwd: '/repo/project', + })) + + const response = await responsePromise + + expect(response).toMatchObject({ + type: 'error', + code: 'RESTORE_UNAVAILABLE', + requestId, + }) + expect(registry.createCallCount).toBe(0) + expect(registry.records.size).toBe(0) + } finally { + await closeWebSocket(ws) + } + }) + + it.each(['shell', 'codex', 'claude', 'opencode'] as const)('creates a fresh %s terminal for explicit restore-failure recovery', async (mode) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = `req-${mode}-fresh-recovery` + const createdPromise = waitForCreated(ws, requestId) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode, + recoveryIntent: 'fresh_after_restore_unavailable', + cwd: '/repo/project', + })) + + await createdPromise + + expect(registry.createCallCount).toBe(1) + expect(registry.records.size).toBe(1) + expect(registry.lastCreateOpts).toMatchObject({ + mode, + cwd: '/repo/project', + }) + if (mode !== 'codex') { + expect(registry.lastCreateOpts?.resumeSessionId).toBeUndefined() + } + } finally { + await closeWebSocket(ws) + } + }) + + it('rejects fresh recovery when restore identity is also supplied', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + try { + await new Promise<void>((resolve) => ws.on('open', () => resolve())) + await waitForReady(ws) + + const requestId = 'req-invalid-fresh-recovery' + const responsePromise = waitForMessage( + ws, + (m) => m.requestId === requestId && (m.type === 'terminal.created' || m.type === 'error'), + ) + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'codex', + recoveryIntent: 'fresh_after_restore_unavailable', + sessionRef: { + provider: 'codex', + sessionId: 'codex-session-1', + }, + })) + + const response = await responsePromise + + expect(response).toMatchObject({ + type: 'error', + code: 'INVALID_CREATE_REQUEST', + requestId, + }) + expect(registry.createCallCount).toBe(0) + expect(registry.records.size).toBe(0) + } finally { + await closeWebSocket(ws) + } + }) }) diff --git a/test/unit/client/agentChatSlice.test.ts b/test/unit/client/agentChatSlice.test.ts index a87b0d627..98f813c4b 100644 --- a/test/unit/client/agentChatSlice.test.ts +++ b/test/unit/client/agentChatSlice.test.ts @@ -506,7 +506,7 @@ describe('agentChatSlice', () => { expect(state.sessions['sdk-live']).toMatchObject({ historyLoaded: true, - timelineSessionId: undefined, + timelineSessionId: 'named-resume', timelineRevision: 1, }) diff --git a/test/unit/client/components/App.ws-bootstrap.test.tsx b/test/unit/client/components/App.ws-bootstrap.test.tsx index fc0dc0e72..cda5fcb7f 100644 --- a/test/unit/client/components/App.ws-bootstrap.test.tsx +++ b/test/unit/client/components/App.ws-bootstrap.test.tsx @@ -11,6 +11,7 @@ import panesReducer from '@/store/panesSlice' import tabRegistryReducer from '@/store/tabRegistrySlice' import terminalMetaReducer from '@/store/terminalMetaSlice' import extensionsReducer from '@/store/extensionsSlice' +import turnCompletionReducer from '@/store/turnCompletionSlice' import { networkReducer } from '@/store/networkSlice' import codexActivityReducer, { type CodexActivityState } from '@/store/codexActivitySlice' import opencodeActivityReducer, { type OpencodeActivityState } from '@/store/opencodeActivitySlice' @@ -53,6 +54,17 @@ const defaultServerSettings = createDefaultServerSettings({ loggingDebug: defaultSettings.logging.debug, }) +function stubAudio(): void { + vi.stubGlobal('Audio', vi.fn(() => ({ + preload: '', + volume: 1, + pause: vi.fn(), + play: vi.fn().mockResolvedValue(undefined), + currentTime: 0, + src: '', + }) as unknown as HTMLAudioElement)) +} + function createSettingsState(options: { server?: ServerSettingsPatch local?: LocalSettingsPatch @@ -81,6 +93,16 @@ const wsMocks = vi.hoisted(() => ({ serverInstanceId: undefined as string | undefined, })) +const terminalRestoreMocks = vi.hoisted(() => ({ + addTerminalRestoreRequestId: vi.fn(), + addTerminalFreshRecoveryRequestId: vi.fn(), +})) + +vi.mock('@/lib/terminal-restore', () => ({ + addTerminalRestoreRequestId: terminalRestoreMocks.addTerminalRestoreRequestId, + addTerminalFreshRecoveryRequestId: terminalRestoreMocks.addTerminalFreshRecoveryRequestId, +})) + let messageHandler: ((msg: any) => void) | null = null let disconnectHandler: (() => void) | null = null @@ -103,6 +125,8 @@ vi.mock('@/lib/ws-client', () => ({ const apiGet = vi.hoisted(() => vi.fn()) const fetchSidebarSessionsSnapshot = vi.hoisted(() => vi.fn()) +const getTerminalDirectoryPage = vi.hoisted(() => vi.fn()) +const searchTerminalView = vi.hoisted(() => vi.fn()) vi.mock('@/lib/api', () => ({ api: { get: (url: string) => apiGet(url), @@ -110,6 +134,8 @@ vi.mock('@/lib/api', () => ({ post: vi.fn().mockResolvedValue({}), }, fetchSidebarSessionsSnapshot: (options?: unknown) => fetchSidebarSessionsSnapshot(options), + getTerminalDirectoryPage: (options?: unknown, init?: unknown) => getTerminalDirectoryPage(options, init), + searchTerminalView: (terminalId: string, query: string, options?: unknown) => searchTerminalView(terminalId, query, options), isApiUnauthorizedError: (err: any) => !!err && typeof err === 'object' && err.status === 401, })) @@ -120,6 +146,7 @@ function createStore(options?: { loaded?: boolean } tabs?: Array<Record<string, unknown>> + activeTabId?: string | null panes?: { layouts: Record<string, unknown> activePane: Record<string, string> @@ -168,6 +195,7 @@ function createStore(options?: { tabRegistry: tabRegistryReducer, terminalMeta: terminalMetaReducer, extensions: extensionsReducer, + turnCompletion: turnCompletionReducer, }, middleware: (getDefault) => getDefault({ @@ -175,7 +203,7 @@ function createStore(options?: { }), preloadedState: { settings: createSettingsState(options?.settings), - tabs: { tabs, activeTabId: (tabs[0]?.id as string | undefined) ?? null }, + tabs: { tabs, activeTabId: options?.activeTabId ?? ((tabs[0]?.id as string | undefined) ?? null) }, connection: { status: 'disconnected' as const, lastError: undefined, @@ -214,6 +242,13 @@ function createStore(options?: { }, terminalMeta: { byTerminalId: {} }, extensions: { entries: [] }, + turnCompletion: { + seq: 0, + lastEvent: null, + pendingEvents: [], + attentionByTab: {}, + attentionByPane: {}, + }, }, }) } @@ -222,6 +257,7 @@ describe('App WS bootstrap recovery', () => { beforeEach(() => { cleanup() vi.resetAllMocks() + stubAudio() wsMocks.onReconnect.mockReturnValue(() => {}) wsMocks.onDisconnect.mockImplementation((cb: () => void) => { disconnectHandler = cb @@ -229,6 +265,8 @@ describe('App WS bootstrap recovery', () => { }) wsMocks.isReady = false wsMocks.serverInstanceId = undefined + terminalRestoreMocks.addTerminalRestoreRequestId.mockClear() + terminalRestoreMocks.addTerminalFreshRecoveryRequestId.mockClear() messageHandler = null disconnectHandler = null @@ -239,6 +277,10 @@ describe('App WS bootstrap recovery', () => { fetchSidebarSessionsSnapshot.mockReset() fetchSidebarSessionsSnapshot.mockResolvedValue([]) + getTerminalDirectoryPage.mockReset() + getTerminalDirectoryPage.mockResolvedValue({ items: [], revision: 1, nextCursor: null }) + searchTerminalView.mockReset() + searchTerminalView.mockResolvedValue({ matches: [] }) // Keep API calls fast and deterministic. apiGet.mockImplementation((url: string) => { @@ -257,6 +299,7 @@ describe('App WS bootstrap recovery', () => { afterEach(() => { cleanup() + vi.unstubAllGlobals() }) it('marks connection as auth-required and skips websocket connect when the bootstrap request returns 401', async () => { @@ -801,6 +844,97 @@ describe('App WS bootstrap recovery', () => { expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'opencode.activity.list' })) }) + it('registers regenerated restart request ids for durable restore and explicit fresh recovery', async () => { + const store = createStore({ + tabs: [{ id: 'tab-restart', mode: 'codex', status: 'running' }], + panes: { + layouts: { + 'tab-restart': { + type: 'split', + id: 'split-root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-codex', + content: { + kind: 'terminal', + createRequestId: 'req-codex-old', + status: 'running', + mode: 'codex', + shell: 'system', + terminalId: 'term-codex-old', + serverInstanceId: 'srv-old', + sessionRef: { + provider: 'codex', + sessionId: 'codex-session-1', + }, + }, + }, + { + type: 'leaf', + id: 'pane-shell', + content: { + kind: 'terminal', + createRequestId: 'req-shell-old', + status: 'running', + mode: 'shell', + shell: 'system', + terminalId: 'term-shell-old', + serverInstanceId: 'srv-old', + }, + }, + ], + }, + }, + activePane: { 'tab-restart': 'pane-codex' }, + }, + }) + + render( + <Provider store={store}> + <App /> + </Provider> + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [], + terminalMeta: [], + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-restart'] + if (!layout || layout.type !== 'split') throw new Error('expected split layout') + const codexPane = layout.children[0] + const shellPane = layout.children[1] + if (codexPane.type !== 'leaf' || shellPane.type !== 'leaf') throw new Error('expected leaf panes') + const codexContent = codexPane.content + const shellContent = shellPane.content + if (codexContent.kind !== 'terminal' || shellContent.kind !== 'terminal') throw new Error('expected terminal panes') + + expect(codexContent.terminalId).toBeUndefined() + expect(codexContent.status).toBe('creating') + expect(codexContent.createRequestId).not.toBe('req-codex-old') + expect(terminalRestoreMocks.addTerminalRestoreRequestId).toHaveBeenCalledWith(codexContent.createRequestId) + + expect(shellContent.terminalId).toBeUndefined() + expect(shellContent.status).toBe('creating') + expect(shellContent.createRequestId).not.toBe('req-shell-old') + expect(terminalRestoreMocks.addTerminalFreshRecoveryRequestId).toHaveBeenCalledWith( + shellContent.createRequestId, + 'fresh_after_restore_unavailable', + ) + }) + }) + it('mounts with legacy ws clients that do not implement onDisconnect', async () => { const store = createStore() const originalOnDisconnect = wsMocks.onDisconnect @@ -908,6 +1042,167 @@ describe('App WS bootstrap recovery', () => { }) }) + it('records OpenCode turn completion from the production WebSocket message path', async () => { + const store = createStore({ + tabs: [{ + id: 'tab-opencode', + createRequestId: 'req-opencode', + title: 'OpenCode', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + createdAt: 1, + }], + panes: { + layouts: { + 'tab-opencode': { + type: 'leaf', + id: 'pane-opencode', + content: { + kind: 'terminal', + createRequestId: 'req-opencode', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + initialCwd: '/workspace', + }, + }, + }, + activePane: { + 'tab-opencode': 'pane-opencode', + }, + }, + }) + wsMocks.isReady = true + wsMocks.serverInstanceId = 'srv-preconnected-opencode-turn-complete' + + render( + <Provider store={store}> + <App /> + </Provider> + ) + + await waitFor(() => { + expect(store.getState().connection.status).toBe('ready') + }) + + act(() => { + messageHandler?.({ + type: 'terminal.turn.complete', + terminalId: 'term-opencode', + provider: 'opencode', + sessionId: 'session-opencode', + at: 1234, + }) + }) + + await waitFor(() => { + expect(store.getState().turnCompletion.lastEvent).toMatchObject({ + tabId: 'tab-opencode', + paneId: 'pane-opencode', + terminalId: 'term-opencode', + at: 1234, + }) + }) + expect(store.getState().turnCompletion.seq).toBe(1) + }) + + it('records OpenCode turn completion against the active tab when a terminal is duplicated', async () => { + const store = createStore({ + activeTabId: 'tab-active', + tabs: [ + { + id: 'tab-background', + createRequestId: 'req-background', + title: 'OpenCode background', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + createdAt: 1, + }, + { + id: 'tab-active', + createRequestId: 'req-active', + title: 'OpenCode active', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + createdAt: 2, + }, + ], + panes: { + layouts: { + 'tab-background': { + type: 'leaf', + id: 'pane-background', + content: { + kind: 'terminal', + createRequestId: 'req-background', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + initialCwd: '/workspace', + }, + }, + 'tab-active': { + type: 'leaf', + id: 'pane-active', + content: { + kind: 'terminal', + createRequestId: 'req-active', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode', + initialCwd: '/workspace', + }, + }, + }, + activePane: { + 'tab-background': 'pane-background', + 'tab-active': 'pane-active', + }, + }, + }) + wsMocks.isReady = true + wsMocks.serverInstanceId = 'srv-preconnected-opencode-turn-complete-duplicate' + + render( + <Provider store={store}> + <App /> + </Provider> + ) + + await waitFor(() => { + expect(store.getState().connection.status).toBe('ready') + }) + + act(() => { + messageHandler?.({ + type: 'terminal.turn.complete', + terminalId: 'term-opencode', + provider: 'opencode', + sessionId: 'session-opencode', + at: 5678, + }) + }) + + await waitFor(() => { + expect(store.getState().turnCompletion.lastEvent).toMatchObject({ + tabId: 'tab-active', + paneId: 'pane-active', + terminalId: 'term-opencode', + at: 5678, + }) + }) + expect(store.getState().turnCompletion.seq).toBe(1) + }) + it('keeps the WS message handler registered after an initial connect failure, so a later ready can recover state', async () => { const store = createStore() @@ -1095,6 +1390,178 @@ describe('App WS bootstrap recovery', () => { }) }) + it('refreshes terminal directory and loaded session rows after terminal metadata invalidation', async () => { + const initialProjects = [{ + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'codex-live-1', + projectPath: '/repo', + lastActivityAt: 1, + title: 'Live Codex', + }], + }] + const refreshedProjects = [{ + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'codex-live-1', + projectPath: '/repo', + lastActivityAt: 2, + title: 'Live Codex', + isRunning: true, + runningTerminalId: 'term-1', + }], + }] + const store = createStore({ + sessions: { + projects: initialProjects, + activeSurface: 'sidebar', + lastLoadedAt: Date.now(), + windows: { + sidebar: { + projects: initialProjects, + lastLoadedAt: Date.now(), + resultVersion: 1, + }, + }, + }, + }) + fetchSidebarSessionsSnapshot.mockResolvedValueOnce({ + projects: refreshedProjects, + totalSessions: 1, + oldestIncludedTimestamp: 2, + oldestIncludedSessionId: 'codex:codex-live-1', + hasMore: false, + }) + + render( + <Provider store={store}> + <App /> + </Provider> + ) + + await waitFor(() => { + expect(wsMocks.connect).toHaveBeenCalledTimes(1) + }) + + act(() => { + messageHandler?.({ + type: 'terminal.meta.updated', + upsert: [{ + terminalId: 'term-1', + provider: 'codex', + sessionId: 'codex-live-1', + updatedAt: 1_700, + }], + remove: [], + }) + messageHandler?.({ + type: 'terminals.changed', + revision: 2, + }) + }) + + expect(store.getState().sessions.projects[0]?.sessions[0]).toMatchObject({ + isRunning: true, + runningTerminalId: 'term-1', + }) + + await waitFor(() => { + expect(getTerminalDirectoryPage).toHaveBeenCalledTimes(1) + expect(fetchSidebarSessionsSnapshot).toHaveBeenCalledTimes(1) + }) + }) + + it('uses terminal inventory metadata to refresh terminal surfaces and mark loaded sessions running', async () => { + const initialProjects = [{ + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'codex-inventory-1', + projectPath: '/repo', + lastActivityAt: 1, + title: 'Inventory Codex', + }], + }] + const refreshedProjects = [{ + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'codex-inventory-1', + projectPath: '/repo', + lastActivityAt: 2, + title: 'Inventory Codex', + isRunning: true, + runningTerminalId: 'term-inventory-1', + }], + }] + const store = createStore({ + sessions: { + projects: initialProjects, + activeSurface: 'sidebar', + lastLoadedAt: Date.now(), + windows: { + sidebar: { + projects: initialProjects, + lastLoadedAt: Date.now(), + resultVersion: 1, + }, + }, + }, + }) + fetchSidebarSessionsSnapshot.mockResolvedValueOnce({ + projects: refreshedProjects, + totalSessions: 1, + oldestIncludedTimestamp: 2, + oldestIncludedSessionId: 'codex:codex-inventory-1', + hasMore: false, + }) + + render( + <Provider store={store}> + <App /> + </Provider> + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + getTerminalDirectoryPage.mockClear() + fetchSidebarSessionsSnapshot.mockClear() + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [{ + terminalId: 'term-inventory-1', + title: 'Codex', + mode: 'codex', + createdAt: 1_000, + lastActivityAt: 1_700, + status: 'running', + }], + terminalMeta: [{ + terminalId: 'term-inventory-1', + provider: 'codex', + sessionId: 'codex-inventory-1', + updatedAt: 1_700, + }], + }) + }) + + expect(store.getState().sessions.projects[0]?.sessions[0]).toMatchObject({ + isRunning: true, + runningTerminalId: 'term-inventory-1', + }) + + await waitFor(() => { + expect(getTerminalDirectoryPage).toHaveBeenCalledTimes(1) + expect(fetchSidebarSessionsSnapshot).toHaveBeenCalledTimes(1) + }) + }) + it('ignores legacy sessions.patch messages when bootstrapping against an already-ready socket', async () => { const baselineProjects = [ { diff --git a/test/unit/client/components/ContextMenuProvider.test.tsx b/test/unit/client/components/ContextMenuProvider.test.tsx index 0eb11204e..073255881 100644 --- a/test/unit/client/components/ContextMenuProvider.test.tsx +++ b/test/unit/client/components/ContextMenuProvider.test.tsx @@ -10,6 +10,7 @@ import sessionsReducer from '@/store/sessionsSlice' import connectionReducer from '@/store/connectionSlice' import settingsReducer from '@/store/settingsSlice' import extensionsReducer from '@/store/extensionsSlice' +import tabRecencyReducer from '@/store/tabRecencySlice' import { ContextMenuProvider } from '@/components/context-menu/ContextMenuProvider' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -587,6 +588,22 @@ describe('ContextMenuProvider', () => { cleanup() vi.clearAllMocks() }) + + it('does not emit selector instability warnings when feature flags are absent', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { store } = renderWithProvider( + <div data-context={ContextIds.Global}>Global area</div>, + ) + + store.dispatch({ type: 'test/unrelated' }) + + expect(consoleWarnSpy.mock.calls.map((call) => String(call[0])).join('\n')).not.toContain('Selector') + } finally { + consoleWarnSpy.mockRestore() + } + }) + it('opens menu on right click and dispatches close tab', async () => { const user = userEvent.setup() const { store } = renderWithProvider( @@ -874,8 +891,10 @@ describe('ContextMenuProvider', () => { expect(newPane).toBeDefined() if (newPane?.type === 'leaf') { expect(newPane.content).toMatchObject({ - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + provider: 'claude', + sessionType: 'freshclaude', + resumeSessionId: VALID_SESSION_ID, sessionRef: { provider: 'claude', sessionId: VALID_SESSION_ID, @@ -1001,6 +1020,246 @@ describe('ContextMenuProvider', () => { expect(clipboardMocks.copyText).toHaveBeenCalledWith(`claude --resume ${VALID_SESSION_ID}`) }) + it('copies session metadata with minute-bucketed open-tab recency', async () => { + const user = userEvent.setup() + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + sessions: sessionsReducer, + connection: connectionReducer, + settings: settingsReducer, + extensions: extensionsReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }), + preloadedState: { + tabs: { + tabs: [ + { + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Claude Tab', + status: 'running', + mode: 'claude', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + ], + activeTabId: 'tab-1', + renameRequestTabId: null, + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'claude', + status: 'running', + createRequestId: 'req-1', + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: { 'tab-1': { 'pane-1': 'Claude Tab' } }, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_080_000, + }, + }, + sessions: { + projects: [ + { + projectPath: '/test/project', + sessions: [ + { + sessionId: VALID_SESSION_ID, + provider: 'claude', + title: 'Test Session', + cwd: '/test/project', + createdAt: 1000, + lastActivityAt: 2000, + messageCount: 5, + }, + ], + }, + ], + expandedProjects: new Set<string>(), + }, + extensions: { + entries: defaultCliExtensions, + }, + connection: { + status: 'ready', + platform: null, + }, + }, + }) + + render( + <Provider store={store}> + <ContextMenuProvider + view="terminal" + onViewChange={() => {}} + onToggleSidebar={() => {}} + sidebarCollapsed={false} + > + <div + data-context={ContextIds.SidebarSession} + data-session-id={VALID_SESSION_ID} + data-provider="claude" + > + Sidebar Session + </div> + </ContextMenuProvider> + </Provider> + ) + + await user.pointer({ target: screen.getByText('Sidebar Session'), keys: '[MouseRight]' }) + await user.click(screen.getByRole('menuitem', { name: 'Copy full metadata' })) + + const copied = JSON.parse(clipboardMocks.copyText.mock.calls.at(-1)?.[0] ?? '{}') + expect(copied.tabLastInputAt).toBe(1_740_000_060_000) + expect(copied.tabLastInputAtIso).toBe(new Date(1_740_000_060_000).toISOString()) + }) + + it('copies session metadata when open-tab recency is the zero bucket', async () => { + const user = userEvent.setup() + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + sessions: sessionsReducer, + connection: connectionReducer, + settings: settingsReducer, + extensions: extensionsReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }), + preloadedState: { + tabs: { + tabs: [ + { + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Claude Tab', + status: 'running', + mode: 'claude', + createdAt: 0, + updatedAt: 999_999, + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + ], + activeTabId: 'tab-1', + renameRequestTabId: null, + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'claude', + status: 'running', + createRequestId: 'req-1', + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: { 'tab-1': { 'pane-1': 'Claude Tab' } }, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 0, + }, + }, + sessions: { + projects: [ + { + projectPath: '/test/project', + sessions: [ + { + sessionId: VALID_SESSION_ID, + provider: 'claude', + title: 'Test Session', + cwd: '/test/project', + createdAt: 1000, + lastActivityAt: 2000, + messageCount: 5, + }, + ], + }, + ], + expandedProjects: new Set<string>(), + }, + extensions: { + entries: defaultCliExtensions, + }, + connection: { + status: 'ready', + platform: null, + }, + }, + }) + + render( + <Provider store={store}> + <ContextMenuProvider + view="terminal" + onViewChange={() => {}} + onToggleSidebar={() => {}} + sidebarCollapsed={false} + > + <div + data-context={ContextIds.SidebarSession} + data-session-id={VALID_SESSION_ID} + data-provider="claude" + > + Sidebar Session + </div> + </ContextMenuProvider> + </Provider> + ) + + await user.pointer({ target: screen.getByText('Sidebar Session'), keys: '[MouseRight]' }) + await user.click(screen.getByRole('menuitem', { name: 'Copy full metadata' })) + + const copied = JSON.parse(clipboardMocks.copyText.mock.calls.at(-1)?.[0] ?? '{}') + expect(copied.tabLastInputAt).toBe(0) + expect(copied.tabLastInputAtIso).toBe(new Date(0).toISOString()) + }) + it('copies resume command from terminal pane context menu for codex pane', async () => { const user = userEvent.setup() const store = configureStore({ diff --git a/test/unit/client/components/HistoryView.mobile.test.tsx b/test/unit/client/components/HistoryView.mobile.test.tsx index f5a571536..290e18829 100644 --- a/test/unit/client/components/HistoryView.mobile.test.tsx +++ b/test/unit/client/components/HistoryView.mobile.test.tsx @@ -103,7 +103,7 @@ describe('HistoryView mobile behavior', () => { expect(screen.getByRole('button', { name: 'Delete session' }).className).toContain('min-h-11') }) - it('opens agent-chat sessions with their sessionType instead of falling back to a terminal tab', async () => { + it('opens fresh-agent sessions with their sessionType instead of falling back to a terminal tab', async () => { const projectPath = '/test/project' const store = configureStore({ reducer: { @@ -167,8 +167,10 @@ describe('HistoryView mobile behavior', () => { expect(layout?.type).toBe('leaf') if (layout?.type === 'leaf') { expect(layout.content).toMatchObject({ - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440000', sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000', diff --git a/test/unit/client/components/SettingsView.agent-chat.test.tsx b/test/unit/client/components/SettingsView.agent-chat.test.tsx index da62a6422..94a5991e4 100644 --- a/test/unit/client/components/SettingsView.agent-chat.test.tsx +++ b/test/unit/client/components/SettingsView.agent-chat.test.tsx @@ -26,14 +26,14 @@ function getToggle(labelText: string) { return row.querySelector('[role="switch"]') as HTMLElement } -describe('SettingsView agent chat settings', () => { - it('renders the Agent chat section on the Workspace tab', () => { +describe('SettingsView fresh agent settings', () => { + it('renders the Fresh agent section on the Workspace tab', () => { const store = createSettingsViewStore() renderSettingsView(store) switchSettingsTab('Workspace') - expect(screen.getByRole('heading', { name: 'Agent chat' })).toBeInTheDocument() - expect(screen.getByText('Display settings for agent chat panes')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Fresh agent' })).toBeInTheDocument() + expect(screen.getByText('Display settings for fresh-agent panes')).toBeInTheDocument() }) it('renders Show thinking, Show tools, and Show timecodes toggles', () => { @@ -56,9 +56,9 @@ describe('SettingsView agent chat settings', () => { expect(getToggle('Show timecodes & model')).toHaveAttribute('aria-checked', 'false') }) - it('reflects current agentChat settings when preloaded', () => { + it('reflects current freshAgent settings when preloaded', () => { const store = createSettingsViewStore({ - settings: { agentChat: { showThinking: true, showTools: true, showTimecodes: true } }, + settings: { freshAgent: { showThinking: true, showTools: true, showTimecodes: true } }, }) renderSettingsView(store) switchSettingsTab('Workspace') @@ -75,6 +75,7 @@ describe('SettingsView agent chat settings', () => { fireEvent.click(getToggle('Show thinking')) + expect(store.getState().settings.settings.freshAgent.showThinking).toBe(true) expect(store.getState().settings.settings.agentChat.showThinking).toBe(true) }) @@ -85,6 +86,7 @@ describe('SettingsView agent chat settings', () => { fireEvent.click(getToggle('Show tools')) + expect(store.getState().settings.settings.freshAgent.showTools).toBe(true) expect(store.getState().settings.settings.agentChat.showTools).toBe(true) }) @@ -95,22 +97,24 @@ describe('SettingsView agent chat settings', () => { fireEvent.click(getToggle('Show timecodes & model')) + expect(store.getState().settings.settings.freshAgent.showTimecodes).toBe(true) expect(store.getState().settings.settings.agentChat.showTimecodes).toBe(true) }) it('toggling off a previously-on setting sets it to false', () => { const store = createSettingsViewStore({ - settings: { agentChat: { showThinking: true } }, + settings: { freshAgent: { showThinking: true }, agentChat: { showThinking: true } }, }) renderSettingsView(store) switchSettingsTab('Workspace') fireEvent.click(getToggle('Show thinking')) + expect(store.getState().settings.settings.freshAgent.showThinking).toBe(false) expect(store.getState().settings.settings.agentChat.showThinking).toBe(false) }) - it('agent chat setting changes are local-only (no api.patch call)', async () => { + it('fresh agent setting changes are local-only (no api.patch call)', async () => { const store = createSettingsViewStore() renderSettingsView(store) switchSettingsTab('Workspace') diff --git a/test/unit/client/components/SettingsView.behavior.test.tsx b/test/unit/client/components/SettingsView.behavior.test.tsx index 72ace3171..e267187ef 100644 --- a/test/unit/client/components/SettingsView.behavior.test.tsx +++ b/test/unit/client/components/SettingsView.behavior.test.tsx @@ -455,6 +455,10 @@ describe('SettingsView behavior sections', () => { remoteOpen: [ makeRegistryRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), ], + devices: [ + { deviceId: 'remote-a', deviceLabel: 'studio-mac', lastSeenAt: 10 }, + { deviceId: 'remote-b', deviceLabel: 'studio-mac', lastSeenAt: 5 }, + ], closed: [ makeRegistryRecord({ deviceId: 'remote-b', @@ -472,16 +476,16 @@ describe('SettingsView behavior sections', () => { renderSettingsView(store) switchSettingsTab('Safety') - expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) + expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(2) - fireEvent.click(screen.getByRole('button', { name: 'Delete device studio-mac' })) + fireEvent.click(screen.getAllByRole('button', { name: 'Delete device studio-mac' })[0]) await act(async () => { await Promise.resolve() }) - expect(screen.queryByLabelText('Device name for studio-mac')).not.toBeInTheDocument() - expect(JSON.parse(localStorage.getItem(DEVICE_DISMISSED_STORAGE_KEY) || '[]').sort()).toEqual(['remote-a', 'remote-b']) + expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) + expect(JSON.parse(localStorage.getItem(DEVICE_DISMISSED_STORAGE_KEY) || '[]')).toEqual(['remote-a']) }) }) }) diff --git a/test/unit/client/components/Sidebar.test.tsx b/test/unit/client/components/Sidebar.test.tsx index 4c7b90d82..c9283f681 100644 --- a/test/unit/client/components/Sidebar.test.tsx +++ b/test/unit/client/components/Sidebar.test.tsx @@ -64,7 +64,7 @@ import { searchSessions as mockSearchSessions } from '@/lib/api' const sessionId = (label: string) => { const hex = createHash('md5').update(label).digest('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}` } function createDeferred<T>() { @@ -410,6 +410,121 @@ describe('Sidebar Component - Session-Centric Display', () => { expect(screen.queryByText('Shell')).not.toBeInTheDocument() }) + it('shows live coding terminals before a provider session id exists', async () => { + const terminals: BackgroundTerminal[] = [ + { + terminalId: 'term-opencode-live', + title: 'OpenCode', + createdAt: Date.now() - 1000, + lastActivityAt: Date.now(), + status: 'running', + hasClients: true, + mode: 'opencode', + cwd: '/home/user/code/freshell', + }, + ] + + const store = createTestStore({ + projects: [], + tabs: [{ + id: 'tab-opencode', + terminalId: 'term-opencode-live', + mode: 'opencode', + status: 'running', + }], + activeTabId: null, + }) + renderSidebar(store, terminals) + + await act(async () => { + await Promise.resolve() + }) + + const item = screen.getByRole('button', { name: /OpenCode/i }) + expect(item).toHaveAttribute('data-provider', 'opencode') + expect(item).toHaveAttribute('data-session-id', 'terminal:term-opencode-live') + expect(item).toHaveAttribute('data-is-running', 'true') + expect(item).toHaveAttribute('data-running-terminal-id', 'term-opencode-live') + }) + + it('focuses an existing pane when a live-only terminal item is clicked', async () => { + const terminals: BackgroundTerminal[] = [ + { + terminalId: 'term-opencode-live', + title: 'OpenCode', + createdAt: Date.now() - 1000, + lastActivityAt: Date.now(), + status: 'running', + hasClients: true, + mode: 'opencode', + cwd: '/home/user/code/freshell', + }, + ] + const store = createTestStore({ + projects: [], + tabs: [ + { id: 'tab-shell', mode: 'shell', status: 'running' }, + { id: 'tab-opencode', terminalId: 'term-opencode-live', mode: 'opencode', status: 'running' }, + ], + activeTabId: 'tab-shell', + }) + const { onNavigate } = renderSidebar(store, terminals) + + await act(async () => { + await Promise.resolve() + }) + + fireEvent.click(screen.getByRole('button', { name: /OpenCode/i })) + + expect(store.getState().tabs.activeTabId).toBe('tab-opencode') + expect(store.getState().panes.activePane['tab-opencode']).toBe('pane-tab-opencode') + expect(onNavigate).toHaveBeenCalledWith('terminal') + }) + + it('opens a live-only terminal without inventing a sessionRef when no pane owns it', async () => { + const terminals: BackgroundTerminal[] = [ + { + terminalId: 'term-opencode-live', + title: 'OpenCode', + createdAt: Date.now() - 1000, + lastActivityAt: Date.now(), + status: 'running', + hasClients: true, + mode: 'opencode', + cwd: '/home/user/code/freshell', + }, + ] + const store = createTestStore({ + projects: [], + tabs: [], + activeTabId: null, + serverInstanceId: 'srv-local', + }) + renderSidebar(store, terminals) + + await act(async () => { + await Promise.resolve() + }) + + fireEvent.click(screen.getByRole('button', { name: /OpenCode/i })) + + const state = store.getState() + const tab = state.tabs.tabs.find((candidate) => candidate.title === 'OpenCode') + expect(tab).toBeTruthy() + const layout = tab ? state.panes.layouts[tab.id] : undefined + expect(layout).toMatchObject({ + type: 'leaf', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'term-opencode-live', + serverInstanceId: 'srv-local', + status: 'running', + }, + }) + expect((layout as any)?.content?.sessionRef).toBeUndefined() + }) + it('shows session title, not terminal title', async () => { const projects: ProjectGroup[] = [ { @@ -1458,6 +1573,141 @@ describe('Sidebar Component - Session-Centric Display', () => { expect(state.tabs.tabs[0].mode).toBe('claude') }) + it('opens a running detached session in tab mode with the live terminal locality', async () => { + const projects: ProjectGroup[] = [ + { + projectPath: '/home/user/project', + sessions: [ + { + provider: 'codex', + sessionId: 'codex-live-tab', + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + title: 'Live Codex Tab', + cwd: '/home/user/project', + isRunning: true, + runningTerminalId: 'term-codex-live-tab', + }, + ], + }, + ] + + const store = createTestStore({ + projects, + serverInstanceId: 'srv-local', + }) + const { onNavigate } = renderSidebar(store, []) + + await act(async () => { + vi.advanceTimersByTime(100) + }) + + const sessionButton = screen.getByText('Live Codex Tab').closest('button') + fireEvent.click(sessionButton!) + + expect(onNavigate).toHaveBeenCalledWith('terminal') + const state = store.getState() + expect(state.tabs.tabs).toHaveLength(1) + const tab = state.tabs.tabs[0] + const layout = state.panes.layouts[tab.id] + expect(layout.type).toBe('leaf') + if (layout.type !== 'leaf') throw new Error('expected leaf layout') + expect(layout.content).toMatchObject({ + kind: 'terminal', + mode: 'codex', + terminalId: 'term-codex-live-tab', + serverInstanceId: 'srv-local', + status: 'running', + sessionRef: { + provider: 'codex', + sessionId: 'codex-live-tab', + }, + }) + }) + + it('opens a running detached session in split mode with the live terminal locality', async () => { + const projects: ProjectGroup[] = [ + { + projectPath: '/home/user/project', + sessions: [ + { + provider: 'codex', + sessionId: 'codex-live-split', + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + title: 'Live Codex Split', + cwd: '/home/user/project', + isRunning: true, + runningTerminalId: 'term-codex-live-split', + }, + ], + }, + ] + + const tabs = [ + { + id: 'tab-1', + mode: 'shell' as const, + }, + ] + + const panes = { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'shell', + createRequestId: 'req-1', + status: 'running', + }, + }, + }, + activePane: { + 'tab-1': 'pane-1', + }, + paneTitles: {}, + } + + const store = createTestStore({ + projects, + tabs, + panes, + activeTabId: 'tab-1', + serverInstanceId: 'srv-local', + sessionOpenMode: 'split', + }) + const { onNavigate } = renderSidebar(store, []) + + await act(async () => { + vi.advanceTimersByTime(100) + }) + + const sessionButton = screen.getByText('Live Codex Split').closest('button') + fireEvent.click(sessionButton!) + + expect(onNavigate).toHaveBeenCalledWith('terminal') + const state = store.getState() + expect(state.tabs.tabs).toHaveLength(1) + const layout = state.panes.layouts['tab-1'] + expect(layout.type).toBe('split') + const sessionPane = collectLeafPanes(layout).find((pane) => ( + pane.type === 'leaf' + && pane.content.kind === 'terminal' + && pane.content.sessionRef?.provider === 'codex' + && pane.content.sessionRef?.sessionId === 'codex-live-split' + )) + expect(sessionPane?.content).toMatchObject({ + kind: 'terminal', + mode: 'codex', + terminalId: 'term-codex-live-split', + serverInstanceId: 'srv-local', + status: 'running', + }) + expect(sessionPane?.content).not.toHaveProperty('liveTerminal') + }) + it('switches to existing tab when clicking non-running session that is already open', async () => { const projects: ProjectGroup[] = [ { diff --git a/test/unit/client/components/SidebarItem.running-state.test.tsx b/test/unit/client/components/SidebarItem.running-state.test.tsx new file mode 100644 index 000000000..7bb89b687 --- /dev/null +++ b/test/unit/client/components/SidebarItem.running-state.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { describe, expect, it } from 'vitest' +import { SidebarItem } from '@/components/Sidebar' + +function renderSidebarItem(item: any) { + const store = configureStore({ + reducer: { + extensions: (state = { entries: [] }) => state, + }, + }) + + return render( + <Provider store={store}> + <SidebarItem + item={item} + isActiveTab={false} + showProjectBadge={false} + onClick={() => {}} + /> + </Provider>, + ) +} + +describe('SidebarItem running state', () => { + it('renders a running detached session as actionable but not open', () => { + renderSidebarItem({ + id: 'session-codex-codex-live-1', + provider: 'codex', + sessionType: 'codex', + sessionId: 'codex-live-1', + title: 'Live Codex', + timestamp: 1_700, + hasTab: false, + isRunning: true, + runningTerminalId: 'term-codex-1', + hasTitle: true, + }) + + const button = screen.getByRole('button', { name: /live codex/i }) + expect(button).toHaveAttribute('data-is-running', 'true') + expect(button).toHaveAttribute('data-has-tab', 'false') + expect(button).toHaveAttribute('data-running-terminal-id', 'term-codex-1') + expect(button.querySelector('svg')).toHaveClass('text-muted-foreground') + expect(button.querySelector('svg')).not.toHaveClass('text-success') + }) + + it('renders an open session as green when not busy', () => { + renderSidebarItem({ + id: 'session-codex-codex-open-1', + provider: 'codex', + sessionType: 'codex', + sessionId: 'codex-open-1', + title: 'Open Codex', + timestamp: 1_700, + hasTab: true, + isRunning: true, + runningTerminalId: 'term-codex-1', + hasTitle: true, + }) + + const button = screen.getByRole('button', { name: /open codex/i }) + expect(button).toHaveAttribute('data-has-tab', 'true') + expect(button.querySelector('svg')).toHaveClass('text-success') + }) + + it('renders an empty running-terminal attribute when detached session is not running', () => { + renderSidebarItem({ + id: 'session-codex-codex-idle-1', + provider: 'codex', + sessionType: 'codex', + sessionId: 'codex-idle-1', + title: 'Idle Codex', + timestamp: 1_700, + hasTab: false, + isRunning: false, + hasTitle: true, + }) + + const button = screen.getByRole('button', { name: /idle codex/i }) + expect(button).toHaveAttribute('data-is-running', 'false') + expect(button).toHaveAttribute('data-running-terminal-id', '') + }) +}) diff --git a/test/unit/client/components/TabBar.mobile.test.tsx b/test/unit/client/components/TabBar.mobile.test.tsx index a7b3a6f6b..bc317009d 100644 --- a/test/unit/client/components/TabBar.mobile.test.tsx +++ b/test/unit/client/components/TabBar.mobile.test.tsx @@ -137,7 +137,7 @@ describe('TabBar sidebar toggle integration', () => { ;(globalThis as any).setMobileForTest?.(false) }) - it('renders show-sidebar button before tabs on desktop when sidebar is collapsed', () => { + it('renders show-sidebar button in a fixed desktop slot when sidebar is collapsed', () => { const onToggleSidebar = vi.fn() const store = createStore(defaultTabsState, defaultPanesState) render( @@ -147,7 +147,10 @@ describe('TabBar sidebar toggle integration', () => { ) const showButton = screen.getByTitle('Show sidebar') + const fixedSlot = screen.getByTestId('desktop-sidebar-reopen-slot') expect(showButton).toBeInTheDocument() + expect(fixedSlot).toContainElement(showButton) + expect(showButton.closest('.overflow-x-auto')).toBeNull() fireEvent.click(showButton) expect(onToggleSidebar).toHaveBeenCalled() }) diff --git a/test/unit/client/components/TabBar.multirow.test.tsx b/test/unit/client/components/TabBar.multirow.test.tsx new file mode 100644 index 000000000..ac0617655 --- /dev/null +++ b/test/unit/client/components/TabBar.multirow.test.tsx @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import TabBar from '@/components/TabBar' +import tabsReducer from '@/store/tabsSlice' +import codingCliReducer from '@/store/codingCliSlice' +import codexActivityReducer from '@/store/codexActivitySlice' +import opencodeActivityReducer from '@/store/opencodeActivitySlice' +import panesReducer from '@/store/panesSlice' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import turnCompletionReducer from '@/store/turnCompletionSlice' +import type { Tab } from '@/store/types' +import { + composeResolvedSettings, + createDefaultServerSettings, + resolveLocalSettings, +} from '@shared/settings' + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ send: vi.fn() }), +})) + +vi.mock('lucide-react', () => ({ + X: ({ className }: { className?: string }) => <svg data-testid="x-icon" className={className} />, + Plus: ({ className }: { className?: string }) => <svg data-testid="plus-icon" className={className} />, + Circle: ({ className }: { className?: string }) => <svg data-testid="circle-icon" className={className} />, + ChevronDown: ({ className }: { className?: string }) => <svg data-testid="chevron-down-icon" className={className} />, + ChevronLeft: ({ className }: { className?: string }) => <svg data-testid="chevron-left-icon" className={className} />, + ChevronRight: ({ className }: { className?: string }) => <svg data-testid="chevron-right-icon" className={className} />, + Terminal: ({ className }: { className?: string }) => <svg data-testid="terminal-icon" className={className} />, + MessageSquare: ({ className }: { className?: string }) => <svg data-testid="message-square-icon" className={className} />, + PanelLeft: ({ className }: { className?: string }) => <svg data-testid="panel-left-icon" className={className} />, +})) + +vi.mock('@/components/icons/PaneIcon', () => ({ + default: ({ content, className }: any) => ( + <svg data-testid="pane-icon" data-content-kind={content?.kind} data-content-mode={content?.mode} className={className} /> + ), +})) + +function createTab(overrides: Partial<Tab> = {}): Tab { + return { + id: `tab-${Math.random().toString(36).slice(2)}`, + createRequestId: 'req-1', + title: 'Terminal 1', + status: 'running', + mode: 'shell', + shell: 'system', + createdAt: Date.now(), + ...overrides, + } +} + +function createStore(options: { tabs: Tab[]; activeTabId: string | null; multirowTabs?: boolean }) { + const localSettings = resolveLocalSettings( + options.multirowTabs ? { panes: { multirowTabs: true } } : undefined, + ) + const serverSettings = createDefaultServerSettings({ + loggingDebug: defaultSettings.logging.debug, + }) + + return configureStore({ + reducer: { + tabs: tabsReducer, + codingCli: codingCliReducer, + codexActivity: codexActivityReducer, + opencodeActivity: opencodeActivityReducer, + panes: panesReducer, + settings: settingsReducer, + turnCompletion: turnCompletionReducer, + }, + preloadedState: { + tabs: { tabs: options.tabs, activeTabId: options.activeTabId, renameRequestTabId: null }, + codingCli: { sessions: {}, pendingRequests: {} }, + codexActivity: { byTerminalId: {}, lastSnapshotSeq: 0, liveMutationSeqByTerminalId: {}, removedMutationSeqByTerminalId: {} }, + opencodeActivity: { byTerminalId: {}, lastSnapshotSeq: 0, liveMutationSeqByTerminalId: {}, removedMutationSeqByTerminalId: {} }, + panes: { layouts: {}, activePane: {}, paneTitles: {} }, + settings: { + serverSettings, + localSettings, + settings: composeResolvedSettings(serverSettings, localSettings), + loaded: true, + }, + turnCompletion: { seq: 0, lastEvent: null, pendingEvents: [], attentionByTab: {} }, + }, + }) +} + +function renderWithStore(ui: React.ReactElement, store: ReturnType<typeof createStore>) { + return render(<Provider store={store}>{ui}</Provider>) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => cleanup()) + +describe('TabBar multirow tabs', () => { + it('uses flex-wrap on the tab strip container when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + const { container } = renderWithStore(<TabBar />, store) + + const flexWrap = container.querySelector('.flex-wrap') + expect(flexWrap).not.toBeNull() + }) + + it('does not use flex-wrap when multirowTabs is disabled (default)', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: false }) + const { container } = renderWithStore(<TabBar />, store) + + const flexWrap = container.querySelector('.flex-wrap') + expect(flexWrap).toBeNull() + }) + + it('uses overflow-x-auto when multirowTabs is disabled (default)', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: false }) + const { container } = renderWithStore(<TabBar />, store) + + const scrollContainer = container.querySelector('.overflow-x-auto') + expect(scrollContainer).not.toBeNull() + }) + + it('does not render scroll arrow buttons when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + renderWithStore(<TabBar />, store) + + const leftBtn = screen.queryByLabelText('Scroll tabs left') + const rightBtn = screen.queryByLabelText('Scroll tabs right') + expect(leftBtn).toBeNull() + expect(rightBtn).toBeNull() + }) + + it('renders scroll arrow buttons when multirowTabs is disabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: false }) + renderWithStore(<TabBar />, store) + + const leftBtn = screen.getByLabelText('Scroll tabs left') + const rightBtn = screen.getByLabelText('Scroll tabs right') + expect(leftBtn).toBeInTheDocument() + expect(rightBtn).toBeInTheDocument() + }) + + it('applies h-auto to the outer wrapper and max-h-32 to the tab strip when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + const { container } = renderWithStore(<TabBar />, store) + + const wrapper = container.firstElementChild as HTMLElement + expect(wrapper.className).toContain('h-auto') + expect(wrapper.className).not.toContain('h-12') + + const tabStrip = container.querySelector('.flex-wrap') + expect(tabStrip).not.toBeNull() + expect(tabStrip!.className).toContain('max-h-32') + }) + + it('applies fixed height to the outer wrapper when multirowTabs is disabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: false }) + const { container } = renderWithStore(<TabBar />, store) + + const wrapper = container.firstElementChild as HTMLElement + expect(wrapper.className).toContain('h-12') + expect(wrapper.className).not.toContain('h-auto') + }) + + it('still renders all tabs when multirowTabs is enabled', () => { + const tabs = [ + createTab({ id: 'tab-1', title: 'Tab 1' }), + createTab({ id: 'tab-2', title: 'Tab 2' }), + createTab({ id: 'tab-3', title: 'Tab 3' }), + ] + const store = createStore({ tabs, activeTabId: 'tab-1', multirowTabs: true }) + renderWithStore(<TabBar />, store) + + expect(screen.getByText('Tab 1')).toBeInTheDocument() + expect(screen.getByText('Tab 2')).toBeInTheDocument() + expect(screen.getByText('Tab 3')).toBeInTheDocument() + }) + + it('still renders the + new tab button when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + renderWithStore(<TabBar />, store) + + const addButton = screen.getByRole('button', { name: 'New shell tab' }) + expect(addButton).toBeInTheDocument() + }) + + it('does not use overflow-y-auto on the tab strip when multirowTabs is disabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: false }) + const { container } = renderWithStore(<TabBar />, store) + + const scrollContainer = container.querySelector('.overflow-x-auto') + expect(scrollContainer).not.toBeNull() + expect(scrollContainer!.className).not.toContain('overflow-y-auto') + }) + + it('uses overflow-y-auto on the tab strip when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + const { container } = renderWithStore(<TabBar />, store) + + const flexWrap = container.querySelector('.flex-wrap') + expect(flexWrap).not.toBeNull() + expect(flexWrap!.className).toContain('overflow-y-auto') + }) + + it('does not apply overflow-x-hidden to the tab strip when multirowTabs is enabled', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + const { container } = renderWithStore(<TabBar />, store) + + const flexWrap = container.querySelector('.flex-wrap') + expect(flexWrap).not.toBeNull() + expect(flexWrap!.className).not.toContain('overflow-x-hidden') + }) + + it('does not apply h-full to sidebar reopen slot in multirow mode', () => { + const tab = createTab({ id: 'tab-1' }) + const store = createStore({ tabs: [tab], activeTabId: 'tab-1', multirowTabs: true }) + const { container } = renderWithStore( + <TabBar sidebarCollapsed={true} onToggleSidebar={() => {}} />, + store, + ) + + const slot = container.querySelector('[data-testid="desktop-sidebar-reopen-slot"]') + expect(slot).not.toBeNull() + expect(slot!.className).not.toContain('h-full') + }) +}) diff --git a/test/unit/client/components/TabContent.test.tsx b/test/unit/client/components/TabContent.test.tsx index 5edaa34f1..b9636d75b 100644 --- a/test/unit/client/components/TabContent.test.tsx +++ b/test/unit/client/components/TabContent.test.tsx @@ -147,7 +147,7 @@ describe('TabContent', () => { expect(mockPaneLayout).not.toHaveBeenCalled() }) - it('restores agent-chat default content for no-layout tabs using persisted session metadata', () => { + it('restores fresh-agent default content for no-layout tabs using persisted session metadata', () => { const store = createStore([ { id: 'tab-1', @@ -173,8 +173,10 @@ describe('TabContent', () => { expect(mockPaneLayout).toHaveBeenCalledWith( expect.objectContaining({ defaultContent: expect.objectContaining({ - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440000', sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000', @@ -185,7 +187,7 @@ describe('TabContent', () => { ) }) - it('restores agent-chat default content for shell-mode no-layout tabs using persisted codingCliProvider metadata', () => { + it('restores fresh-agent default content for shell-mode no-layout tabs using persisted codingCliProvider metadata', () => { const store = createStore([ { id: 'tab-1', @@ -212,8 +214,10 @@ describe('TabContent', () => { expect(mockPaneLayout).toHaveBeenCalledWith( expect.objectContaining({ defaultContent: expect.objectContaining({ - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440001', sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440001', diff --git a/test/unit/client/components/TabsView.fresh-agent.test.tsx b/test/unit/client/components/TabsView.fresh-agent.test.tsx new file mode 100644 index 000000000..45162e590 --- /dev/null +++ b/test/unit/client/components/TabsView.fresh-agent.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' + +import TabsView from '@/components/TabsView' +import tabsReducer from '@/store/tabsSlice' +import panesReducer from '@/store/panesSlice' +import tabRegistryReducer, { setTabRegistrySnapshot } from '@/store/tabRegistrySlice' +import connectionReducer, { setServerInstanceId } from '@/store/connectionSlice' + +const wsMock = { + state: 'ready', + sendTabsSyncQuery: vi.fn(), + sendTabsSyncPush: vi.fn(), + onMessage: vi.fn(() => () => {}), + onReconnect: vi.fn(() => () => {}), +} + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMock, +})) + +vi.mock('@/lib/clipboard', () => ({ + copyText: vi.fn(() => Promise.resolve(true)), +})) + +describe('TabsView fresh-agent reopen', () => { + it('serializes fresh-agent panes in remote snapshots and rehydrates them back into fresh-agent panes', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) + store.dispatch(setServerInstanceId('srv-local')) + store.dispatch(setTabRegistrySnapshot({ + localOpen: [], + remoteOpen: [{ + tabKey: 'remote:fresh-agent', + tabId: 'open-1', + serverInstanceId: 'srv-remote', + deviceId: 'remote', + deviceLabel: 'remote-device', + tabName: 'fresh agent remote', + status: 'open', + revision: 2, + createdAt: 1, + updatedAt: 2, + paneCount: 1, + titleSetByUser: false, + panes: [{ + paneId: 'pane-1', + kind: 'fresh-agent', + payload: { + provider: 'claude', + sessionType: 'freshclaude', + resumeSessionId: 'resume-1', + sessionRef: { + provider: 'claude', + sessionId: 'resume-1', + serverInstanceId: 'srv-remote', + }, + }, + }], + }], + closed: [], + })) + + render( + <Provider store={store}> + <TabsView /> + </Provider>, + ) + + fireEvent.click(screen.getByLabelText('remote-device: fresh agent remote')) + + const openedTab = store.getState().tabs.tabs.find((tab) => tab.title === 'fresh agent remote') + expect(openedTab).toBeTruthy() + const layout = openedTab ? (store.getState().panes.layouts[openedTab.id] as any) : undefined + expect(layout?.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) +}) diff --git a/test/unit/client/components/TabsView.test.tsx b/test/unit/client/components/TabsView.test.tsx index cb1e5ef5a..6d58594a6 100644 --- a/test/unit/client/components/TabsView.test.tsx +++ b/test/unit/client/components/TabsView.test.tsx @@ -169,6 +169,11 @@ describe('TabsView', () => { payload: { provider: 'freshclaude', resumeSessionId: '00000000-0000-4000-8000-000000000444', + sessionRef: { + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000444', + serverInstanceId: 'srv-remote', + }, modelSelection: { kind: 'tracked', modelId: 'opus[1m]' }, permissionMode: 'plan', effort: 'turbo', @@ -193,14 +198,15 @@ describe('TabsView', () => { const copiedLayout = store.getState().panes.layouts[copiedTab.id] as any expect(copiedLayout.content).toMatchObject({ - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', resumeSessionId: undefined, sessionRef: { provider: 'claude', sessionId: '00000000-0000-4000-8000-000000000444', - serverInstanceId: 'srv-remote', }, + serverInstanceId: 'srv-remote', modelSelection: { kind: 'tracked', modelId: 'opus[1m]' }, permissionMode: 'plan', effort: 'turbo', @@ -366,8 +372,8 @@ describe('TabsView', () => { expect(layout?.content?.sessionRef).toEqual({ provider: 'codex', sessionId: 'codex-session-123', - serverInstanceId: 'srv-remote', }) + expect(layout?.content?.serverInstanceId).toBe('srv-remote') }) it('shows pane kind icons with distinct colors', () => { diff --git a/test/unit/client/components/TerminalView.lastInputAt.test.tsx b/test/unit/client/components/TerminalView.lastInputAt.test.tsx index 8af9ff6f2..909a4879a 100644 --- a/test/unit/client/components/TerminalView.lastInputAt.test.tsx +++ b/test/unit/client/components/TerminalView.lastInputAt.test.tsx @@ -7,6 +7,7 @@ import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import sessionActivityReducer from '@/store/sessionActivitySlice' +import tabRecencyReducer from '@/store/tabRecencySlice' import TerminalView from '@/components/TerminalView' import type { TerminalPaneContent } from '@/store/paneTypes' @@ -71,7 +72,11 @@ describe('TerminalView - lastInputAt updates', () => { const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' - function createStore(opts?: { resumeSessionId?: string; provider?: 'claude' | 'codex' }) { + function createStore(opts?: { + resumeSessionId?: string + provider?: 'claude' | 'codex' + paneLastInputAt?: Record<string, number> + }) { const provider = opts?.provider || (opts?.resumeSessionId ? 'claude' : undefined) return configureStore({ reducer: { @@ -80,6 +85,7 @@ describe('TerminalView - lastInputAt updates', () => { settings: settingsReducer, connection: connectionReducer, sessionActivity: sessionActivityReducer, + tabRecency: tabRecencyReducer, }, preloadedState: { tabs: { @@ -109,12 +115,19 @@ describe('TerminalView - lastInputAt updates', () => { sessionActivity: { sessions: {}, }, + tabRecency: { + paneLastInputAt: opts?.paneLastInputAt ?? {}, + }, }, }) } - it('dispatches updateTab with lastInputAt when user types', async () => { + it('records one minute-bucketed tab recency action per pane per minute without mutating tabs', async () => { + vi.setSystemTime(new Date(1_740_000_010_000)) const store = createStore() + const originalDispatch = store.dispatch + const dispatchSpy = vi.fn((action) => originalDispatch(action)) + store.dispatch = dispatchSpy as typeof store.dispatch const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: 'req-1', @@ -135,13 +148,63 @@ describe('TerminalView - lastInputAt updates', () => { ) expect(onDataCallback).not.toBeNull() - const beforeInput = Date.now() + dispatchSpy.mockClear() onDataCallback!('hello') - const afterInput = Date.now() - const tab = store.getState().tabs.tabs[0] - expect(tab.lastInputAt).toBeGreaterThanOrEqual(beforeInput) - expect(tab.lastInputAt).toBeLessThanOrEqual(afterInput) + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + expect(store.getState().tabs.tabs[0].lastInputAt).toBeUndefined() + + onDataCallback!('same-minute') + onDataCallback!('same-minute-again') + + const actionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(actionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(1) + expect(actionTypes.filter((type) => type === 'tabs/updateTab')).toHaveLength(0) + + vi.setSystemTime(new Date(1_740_000_060_000)) + onDataCallback!('next-minute') + + const nextActionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(nextActionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(2) + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_060_000) + }) + + it('does not dispatch a same-minute no-op recency action after reload', async () => { + vi.setSystemTime(new Date(1_740_000_050_000)) + const store = createStore({ + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + const originalDispatch = store.dispatch + const dispatchSpy = vi.fn((action) => originalDispatch(action)) + store.dispatch = dispatchSpy as typeof store.dispatch + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-1', + terminalId: 'term-1', + mode: 'shell', + shell: 'system', + status: 'running', + } + + render( + <Provider store={store}> + <TerminalView + tabId="tab-1" + paneId="pane-1" + paneContent={paneContent} + /> + </Provider> + ) + + expect(onDataCallback).not.toBeNull() + dispatchSpy.mockClear() + onDataCallback!('same-minute-after-reload') + + const actionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(actionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(0) + expect(actionTypes.filter((type) => type === 'tabs/updateTab')).toHaveLength(0) }) it('updates sessionActivity for Claude sessions with resumeSessionId', async () => { diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 572373112..955e50494 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -37,6 +37,8 @@ const terminalThemeMocks = vi.hoisted(() => ({ const restoreMocks = vi.hoisted(() => ({ consumeTerminalRestoreRequestId: vi.fn(() => false), addTerminalRestoreRequestId: vi.fn(), + consumeTerminalFreshRecoveryRequest: vi.fn(() => undefined), + addTerminalFreshRecoveryRequestId: vi.fn(), })) const runtimeMocks = vi.hoisted(() => ({ @@ -59,6 +61,8 @@ vi.mock('@/lib/terminal-themes', () => ({ vi.mock('@/lib/terminal-restore', () => ({ consumeTerminalRestoreRequestId: restoreMocks.consumeTerminalRestoreRequestId, addTerminalRestoreRequestId: restoreMocks.addTerminalRestoreRequestId, + consumeTerminalFreshRecoveryRequest: restoreMocks.consumeTerminalFreshRecoveryRequest, + addTerminalFreshRecoveryRequestId: restoreMocks.addTerminalFreshRecoveryRequestId, })) vi.mock('lucide-react', () => ({ @@ -2486,6 +2490,38 @@ describe('TerminalView lifecycle updates', () => { expect(writelnCalls.some((s: string) => s.includes('Terminal exited'))).toBe(true) }) + it('shows feedback when Codex input is blocked by the restore identity gate', async () => { + const { store, tabId, paneId, paneContent } = setupThemeTerminal({ + terminalId: 'term-codex', + status: 'running', + mode: 'codex', + }) + + render( + <Provider store={store}> + <TerminalView tabId={tabId} paneId={paneId} paneContent={paneContent} /> + </Provider> + ) + + await waitFor(() => { + expect(messageHandler).not.toBeNull() + expect(terminalInstances.length).toBeGreaterThan(0) + }) + + act(() => { + messageHandler!({ + type: 'terminal.input.blocked', + terminalId: 'term-codex', + reason: 'codex_identity_pending', + }) + }) + + const term = terminalInstances[0] + expect(term.writeln).toHaveBeenCalledWith( + expect.stringContaining('Input not sent: Codex is still saving restore state. Try again in a moment.'), + ) + }) + it('mirrors canonical durable identity to pane and tab on terminal.session.associated', async () => { const tabId = 'tab-session-assoc' const paneId = 'pane-session-assoc' @@ -2798,7 +2834,7 @@ describe('TerminalView lifecycle updates', () => { }) }) - it('surfaces restore-unavailable for a live-only INVALID_TERMINAL_ID reconnect', async () => { + it('starts explicit fresh recovery for a live-only INVALID_TERMINAL_ID reconnect', async () => { const tabId = 'tab-clear-tid' const paneId = 'pane-clear-tid' @@ -2869,18 +2905,21 @@ describe('TerminalView lifecycle updates', () => { expect(layout.content.terminalId).toBeUndefined() }) - // Verify tab status was set to an explicit restore failure + // Verify tab status moved into explicit fresh recovery rather than a permanent restore error const tab = store.getState().tabs.tabs.find(t => t.id === tabId) - expect(tab?.status).toBe('error') + expect(tab?.status).toBe('creating') // Verify pane content was also updated const layout = store.getState().panes.layouts[tabId] as { type: 'leaf'; content: any } expect(layout.content.terminalId).toBeUndefined() - expect(layout.content.status).toBe('error') - expect(layout.content.restoreError).toEqual({ - code: 'RESTORE_UNAVAILABLE', - reason: 'dead_live_handle', - }) + expect(layout.content.serverInstanceId).toBeUndefined() + expect(layout.content.status).toBe('creating') + expect(layout.content.restoreError).toBeUndefined() + expect(layout.content.createRequestId).not.toBe('req-clear') + expect(restoreMocks.addTerminalFreshRecoveryRequestId).toHaveBeenCalledWith( + layout.content.createRequestId, + 'fresh_after_restore_unavailable', + ) expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('[TerminalView]'), 'restore_unavailable', @@ -3091,6 +3130,7 @@ describe('TerminalView lifecycle updates', () => { async function renderTerminalHarness(opts?: { status?: 'creating' | 'running' terminalId?: string + mode?: TerminalPaneContent['mode'] hidden?: boolean clearSends?: boolean requestId?: string @@ -3102,12 +3142,13 @@ describe('TerminalView lifecycle updates', () => { const requestId = opts?.requestId ?? 'req-v2-stream' const initialStatus = opts?.status ?? 'running' const terminalId = opts?.terminalId + const mode = opts?.mode ?? 'shell' const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: requestId, status: initialStatus, - mode: 'shell', + mode, shell: 'system', ...(terminalId ? { terminalId } : {}), } @@ -3126,9 +3167,9 @@ describe('TerminalView lifecycle updates', () => { tabs: { tabs: [{ id: tabId, - mode: 'shell', + mode, status: initialStatus, - title: 'Shell', + title: mode === 'opencode' ? 'OpenCode' : 'Shell', titleSetByUser: false, createRequestId: requestId, ...(terminalId ? { terminalId } : {}), @@ -3977,6 +4018,27 @@ describe('TerminalView lifecycle updates', () => { })) }) + it('does not cap OpenCode viewport hydration replay for restored running terminals', async () => { + const { terminalId } = await renderTerminalHarness({ + status: 'running', + terminalId: 'term-opencode-restored', + mode: 'opencode', + clearSends: false, + }) + + const attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId) + + expect(attach).toMatchObject({ + type: 'terminal.attach', + terminalId, + intent: 'viewport_hydrate', + sinceSeq: 0, + }) + expect(attach).not.toHaveProperty('maxReplayBytes') + }) + it('revealing a hidden running pane sends a viewport attach with sinceSeq=0', async () => { const { store, tabId, paneId, terminalId, rerender } = await renderTerminalHarness({ status: 'running', diff --git a/test/unit/client/components/TerminalView.resumeSession.test.tsx b/test/unit/client/components/TerminalView.resumeSession.test.tsx index d1fb1efcc..a3bfb9317 100644 --- a/test/unit/client/components/TerminalView.resumeSession.test.tsx +++ b/test/unit/client/components/TerminalView.resumeSession.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, cleanup, waitFor } from '@testing-library/react' import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' +import { useAppSelector } from '@/store/hooks' import tabsReducer from '@/store/tabsSlice' import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' @@ -67,6 +68,16 @@ vi.mock('@xterm/xterm/css/xterm.css', () => ({})) import TerminalView from '@/components/TerminalView' +function TerminalViewFromStore({ tabId, paneId }: { tabId: string; paneId: string }) { + const paneContent = useAppSelector((state) => { + const layout = state.panes.layouts[tabId] + if (!layout || layout.type !== 'leaf') return null + return layout.content + }) + if (!paneContent || paneContent.kind !== 'terminal') return null + return <TerminalView tabId={tabId} paneId={paneId} paneContent={paneContent} /> +} + class MockResizeObserver { observe = vi.fn() disconnect = vi.fn() @@ -323,4 +334,117 @@ describe('TerminalView durable session contract', () => { }) }) }) + + it('creates a fresh terminal once after invalid terminal id with no durable session ref', async () => { + const tabId = 'tab-opencode' + const paneId = 'pane-opencode' + let messageHandler: ((msg: any) => void) | null = null + + wsMocks.onMessage.mockImplementation((handler: (msg: any) => void) => { + messageHandler = handler + return () => {} + }) + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-opencode-fresh-fallback', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'dead-term-1', + serverInstanceId: 'srv-old', + initialCwd: '/repo/project', + } + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode: 'opencode', + status: 'running', + title: 'OpenCode', + titleSetByUser: false, + createRequestId: 'req-opencode-fresh-fallback', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' }, + connection: { status: 'connected', error: null }, + }, + }) + + render( + <Provider store={store}> + <TerminalViewFromStore tabId={tabId} paneId={paneId} /> + </Provider>, + ) + + await waitFor(() => { + expect(messageHandler).not.toBeNull() + }) + wsMocks.send.mockClear() + + messageHandler?.({ + type: 'error', + code: 'INVALID_TERMINAL_ID', + terminalId: 'dead-term-1', + message: 'Unknown terminalId', + }) + + await waitFor(() => { + const createMessage = wsMocks.send.mock.calls.find(([msg]) => ( + msg.type === 'terminal.create' + && msg.mode === 'opencode' + && msg.recoveryIntent === 'fresh_after_restore_unavailable' + ))?.[0] + expect(createMessage).toMatchObject({ + type: 'terminal.create', + mode: 'opencode', + recoveryIntent: 'fresh_after_restore_unavailable', + }) + expect(createMessage).not.toHaveProperty('restore') + expect(createMessage).not.toHaveProperty('sessionRef') + expect(createMessage).not.toHaveProperty('liveTerminal') + expect(createMessage).not.toHaveProperty('resumeSessionId') + }) + + const firstFreshCreates = wsMocks.send.mock.calls.filter(([msg]) => ( + msg.type === 'terminal.create' + && msg.mode === 'opencode' + && msg.recoveryIntent === 'fresh_after_restore_unavailable' + && msg.restore !== true + && !('sessionRef' in msg) + && !('liveTerminal' in msg) + )) + expect(firstFreshCreates).toHaveLength(1) + + wsMocks.send.mockClear() + messageHandler?.({ + type: 'error', + code: 'INVALID_TERMINAL_ID', + terminalId: 'dead-term-1', + message: 'Unknown terminalId', + }) + + await waitFor(() => { + const secondFreshCreates = wsMocks.send.mock.calls.filter(([msg]) => ( + msg.type === 'terminal.create' + && msg.mode === 'opencode' + && msg.recoveryIntent === 'fresh_after_restore_unavailable' + )) + expect(secondFreshCreates).toHaveLength(0) + }) + }) }) diff --git a/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx b/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx index 06a362dc8..5d763c363 100644 --- a/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx +++ b/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx @@ -111,7 +111,7 @@ const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { cli: { terminalBehavior: { preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }, }, } @@ -193,7 +193,7 @@ describe('TerminalView wheel scroll input policy', () => { vi.unstubAllGlobals() }) - it('loads the extension registry for coding-cli panes before applying wheel translation', async () => { + it('loads the extension registry for coding-cli panes and does not translate when policy is native', async () => { authMocks.getAuthToken.mockReturnValue('token-test') apiMocks.get.mockResolvedValue([opencodeExtensionWithBehaviorHint]) const { store, tabId, paneId, paneContent } = createStore('opencode', [], 'term-opencode') @@ -212,15 +212,11 @@ describe('TerminalView wheel scroll input policy', () => { wsMocks.send.mockClear() const event = new WheelEvent('wheel', { deltaY: 24, cancelable: true }) - expect(wheelHandler?.(event)).toBe(false) - expect(wsMocks.send).toHaveBeenCalledWith({ - type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', - }) + expect(wheelHandler?.(event)).toBe(true) + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input' })) }) - it('translates wheel scrolling into cursor-key input for opted-in providers', async () => { + it('does not translate wheel scrolling for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint], 'term-opencode') render( @@ -242,14 +238,10 @@ describe('TerminalView wheel scroll input policy', () => { preventDefault, stopPropagation, } as unknown as WheelEvent - expect(wheelHandler?.(event)).toBe(false) - expect(preventDefault).toHaveBeenCalledOnce() - expect(stopPropagation).toHaveBeenCalledOnce() - expect(wsMocks.send).toHaveBeenCalledWith({ - type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', - }) + expect(wheelHandler?.(event)).toBe(true) + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input' })) }) it('keeps non-opted-in providers on native wheel behavior', async () => { @@ -307,11 +299,9 @@ describe('TerminalView wheel scroll input policy', () => { }) wsMocks.send.mockClear() - expect(wheelHandler?.(event)).toBe(false) - expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + expect(wheelHandler?.(event)).toBe(true) + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', })) }) }) diff --git a/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx index d1f92c682..1c1f1e923 100644 --- a/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx +++ b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx @@ -101,7 +101,7 @@ const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { cli: { terminalBehavior: { preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }, }, } @@ -177,7 +177,7 @@ describe('TerminalView touch scroll input policy', () => { ;(globalThis as any).setMobileForTest(false) }) - it('translates touch scrolling into cursor-key input for opted-in providers', async () => { + it('does not translate touch scrolling for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) const { getByTestId } = render( @@ -203,10 +203,8 @@ describe('TerminalView touch scroll input policy', () => { }) expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() - expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', - terminalId: 'term-opencode', - data: '\u001b[B', })) }) }) diff --git a/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx b/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx index 7d7176938..7f1be8306 100644 --- a/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx +++ b/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx @@ -22,7 +22,7 @@ import panesReducer, { initLayout } from '@/store/panesSlice' import { flushPersistedLayoutNow } from '@/store/persistControl' import settingsReducer from '@/store/settingsSlice' import tabsReducer, { addTab } from '@/store/tabsSlice' -import type { AgentChatPaneContent } from '@/store/paneTypes' +import type { AgentChatPaneContent, FreshAgentPaneContent, PaneContent } from '@/store/paneTypes' import type { PaneNode } from '@/store/paneTypes' // jsdom doesn't implement scrollIntoView @@ -150,6 +150,19 @@ const RELOAD_PANE_WITH_NAMED_RESUME: AgentChatPaneContent = { resumeSessionId: 'named-resume-token', } +function normalizeAgentChatPaneContent(content: PaneContent | undefined): AgentChatPaneContent | undefined { + if (!content) return undefined + if (content.kind === 'agent-chat') return content + if (content.kind !== 'fresh-agent') return undefined + if (content.sessionType !== 'freshclaude' && content.sessionType !== 'kilroy') return undefined + const migrated: FreshAgentPaneContent = content + return { + ...migrated, + kind: 'agent-chat', + provider: migrated.sessionType, + } +} + describe('AgentChatView reload/restore behavior', () => { beforeEach(() => { localStorage.clear() @@ -283,9 +296,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -354,9 +365,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -392,9 +401,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -448,9 +455,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -488,9 +493,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -548,9 +551,7 @@ describe('AgentChatView reload/restore behavior', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -840,7 +841,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sess-reload-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', revision: 12, })) @@ -856,14 +857,14 @@ describe('AgentChatView reload/restore behavior', () => { expect(attachCalls[1]?.[0]).toEqual({ type: 'sdk.attach', sessionId: 'sess-reload-1', - resumeSessionId: 'cli-sess-1', + resumeSessionId: '00000000-0000-4000-8000-000000000101', }) }) }) it('clears stale hydrated timeline content and waits for a fresh snapshot before rereading after a stale restore retry', async () => { getAgentTimelinePage.mockResolvedValue({ - sessionId: 'cli-sess-1', + sessionId: '00000000-0000-4000-8000-000000000101', items: [], nextCursor: null, revision: 13, @@ -874,14 +875,14 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sess-reload-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', revision: 12, })) store.dispatch(timelinePageReceived({ sessionId: 'sess-reload-1', items: [ makeTimelineItem('turn-2', 'assistant', 'Old stale summary', { - sessionId: 'cli-sess-1', + sessionId: '00000000-0000-4000-8000-000000000101', ordinal: 2, timestamp: '2026-03-10T10:01:00.000Z', }), @@ -891,7 +892,7 @@ describe('AgentChatView reload/restore behavior', () => { replace: true, bodies: { 'turn-2': makeTimelineTurn('turn-2', 'assistant', 'Old hydrated body', { - sessionId: 'cli-sess-1', + sessionId: '00000000-0000-4000-8000-000000000101', ordinal: 2, timestamp: '2026-03-10T10:01:00.000Z', }), @@ -939,7 +940,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sess-reload-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', revision: 12, })) @@ -954,7 +955,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sess-reload-1', items: [ makeTimelineItem('turn-2', 'user', 'Hydrated summary', { - sessionId: 'cli-sess-1', + sessionId: '00000000-0000-4000-8000-000000000101', ordinal: 2, timestamp: '2026-03-10T10:01:00.000Z', }), @@ -966,7 +967,7 @@ describe('AgentChatView reload/restore behavior', () => { store.dispatch(turnBodyReceived({ sessionId: 'sess-reload-1', turn: makeTimelineTurn('turn-2', 'user', 'Hydrated body', { - sessionId: 'cli-sess-1', + sessionId: '00000000-0000-4000-8000-000000000101', ordinal: 2, timestamp: '2026-03-10T10:01:00.000Z', }), @@ -980,7 +981,7 @@ describe('AgentChatView reload/restore behavior', () => { await act(async () => { await store.dispatch(loadAgentTurnBody({ sessionId: 'sess-reload-1', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', turnId: 'turn-7', })) }) @@ -1077,14 +1078,19 @@ describe('AgentChatView reload/restore behavior', () => { }) it('uses timelineSessionId from sdk.session.snapshot for visible restore hydration', async () => { - getAgentTimelinePage.mockResolvedValue({ sessionId: 'cli-sess-1', items: [], nextCursor: null, revision: 1 }) + getAgentTimelinePage.mockResolvedValue({ + sessionId: '00000000-0000-4000-8000-000000000101', + items: [], + nextCursor: null, + revision: 1, + }) const store = makeStore() store.dispatch(sessionSnapshotReceived({ sessionId: 'sess-reload-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', revision: 2, })) @@ -1096,7 +1102,7 @@ describe('AgentChatView reload/restore behavior', () => { await waitFor(() => { expect(getAgentTimelinePage).toHaveBeenCalledWith( - 'cli-sess-1', + '00000000-0000-4000-8000-000000000101', expect.objectContaining({ includeBodies: true, revision: 2 }), expect.anything(), ) @@ -1164,15 +1170,22 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sdk-sess-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-session-abc-123', + timelineSessionId: '00000000-0000-4000-8000-000000000201', revision: 2, })) }) - expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't1', 'p1')?.resumeSessionId).toBe('cli-session-abc-123') + expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't1', 'p1')?.sessionRef).toEqual({ + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000201', + }) const tab = store.getState().tabs.tabs.find((entry) => entry.id === 't1') - expect(tab?.resumeSessionId).toBe('cli-session-abc-123') - expect(tab?.sessionMetadataByKey?.['claude:cli-session-abc-123']).toEqual(expect.objectContaining({ + expect(tab?.sessionRef).toEqual({ + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000201', + }) + expect(tab?.resumeSessionId).toBeUndefined() + expect(tab?.sessionMetadataByKey?.['claude:00000000-0000-4000-8000-000000000201']).toEqual(expect.objectContaining({ sessionType: 'freshclaude', firstUserMessage: 'Continue from the old tab', })) @@ -1212,16 +1225,23 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sdk-shell-1', latestTurnId: 'turn-2', status: 'idle', - timelineSessionId: 'cli-shell-abc-123', + timelineSessionId: '00000000-0000-4000-8000-000000000202', revision: 2, })) }) - expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't-shell', 'p1')?.resumeSessionId).toBe('cli-shell-abc-123') + expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't-shell', 'p1')?.sessionRef).toEqual({ + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000202', + }) const tab = store.getState().tabs.tabs.find((entry) => entry.id === 't-shell') - expect(tab?.resumeSessionId).toBe('cli-shell-abc-123') + expect(tab?.sessionRef).toEqual({ + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000202', + }) + expect(tab?.resumeSessionId).toBeUndefined() expect(tab?.codingCliProvider).toBe('claude') - expect(tab?.sessionMetadataByKey?.['claude:cli-shell-abc-123']).toEqual(expect.objectContaining({ + expect(tab?.sessionMetadataByKey?.['claude:00000000-0000-4000-8000-000000000202']).toEqual(expect.objectContaining({ sessionType: 'freshclaude', firstUserMessage: 'Continue from shell fallback', })) @@ -1440,9 +1460,16 @@ describe('AgentChatView reload/restore behavior', () => { expect(screen.queryByText('Live-only full body')).not.toBeInTheDocument() expect(screen.getAllByText('Post-watermark live delta')).toHaveLength(1) - expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't-meta', 'p1')?.resumeSessionId).toBe(canonicalSessionId) + expect(getPaneContent(store as unknown as ReturnType<typeof makeStore>, 't-meta', 'p1')?.sessionRef).toEqual({ + provider: 'claude', + sessionId: canonicalSessionId, + }) const tab = store.getState().tabs.tabs.find((entry) => entry.id === 't-meta') - expect(tab?.resumeSessionId).toBe(canonicalSessionId) + expect(tab?.sessionRef).toEqual({ + provider: 'claude', + sessionId: canonicalSessionId, + }) + expect(tab?.resumeSessionId).toBeUndefined() expect(tab?.sessionMetadataByKey?.['claude:00000000-0000-4000-8000-000000000321']).toEqual(expect.objectContaining({ sessionType: 'freshclaude', firstUserMessage: 'Continue from metadata upgrade', @@ -1503,7 +1530,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sdk-sess-1', latestTurnId: 'turn-2', status: 'running', - timelineSessionId: 'cli-sess-1', + timelineSessionId: '00000000-0000-4000-8000-000000000101', streamingActive: true, streamingText: 'partial reply', })) @@ -1524,7 +1551,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sdk-sess-running', latestTurnId: 'turn-2', status: 'running', - timelineSessionId: 'cli-sess-running', + timelineSessionId: '00000000-0000-4000-8000-000000000301', streamingActive: true, streamingText: 'partial reply', })) @@ -1540,7 +1567,7 @@ describe('AgentChatView reload/restore behavior', () => { act(() => { store.dispatch(sessionInit({ sessionId: 'sdk-sess-running', - cliSessionId: 'cli-sess-running', + cliSessionId: '00000000-0000-4000-8000-000000000301', model: 'claude-opus-4-6', })) }) @@ -1558,7 +1585,7 @@ describe('AgentChatView reload/restore behavior', () => { sessionId: 'sdk-sess-2', latestTurnId: 'turn-3', status: 'running', - timelineSessionId: 'cli-sess-2', + timelineSessionId: '00000000-0000-4000-8000-000000000102', streamingActive: false, streamingText: 'partial reply', })) @@ -1852,8 +1879,8 @@ function getPaneContent(store: ReturnType<typeof makeStore>, tabId: string, pane const root = store.getState().panes.layouts[tabId] if (!root) return undefined function find(node: PaneNode): AgentChatPaneContent | undefined { - if (node.type === 'leaf' && node.id === paneId && node.content.kind === 'agent-chat') { - return node.content + if (node.type === 'leaf' && node.id === paneId) { + return normalizeAgentChatPaneContent(node.content) } if (node.type === 'split') { return find(node.children[0]) || find(node.children[1]) @@ -1893,14 +1920,18 @@ describe('AgentChatView server-restart recovery', () => { store.dispatch(sessionCreated({ requestId: 'req-1', sessionId: 'sdk-sess-1' })) store.dispatch(sessionInit({ sessionId: 'sdk-sess-1', - cliSessionId: 'cli-session-abc-123', + cliSessionId: '00000000-0000-4000-8000-000000000201', model: 'claude-opus-4-6', })) }) - // Pane content should now have resumeSessionId persisted + // Pane content should now have the durable Claude sessionRef persisted. const content = getPaneContent(store, 't1', 'p1') - expect(content?.resumeSessionId).toBe('cli-session-abc-123') + expect(content?.sessionRef).toEqual({ + provider: 'claude', + sessionId: '00000000-0000-4000-8000-000000000201', + }) + expect(content?.resumeSessionId).toBeUndefined() }) it('does not reset the pane or send sdk.create when restore remains pending past the legacy timeout window', () => { @@ -1972,9 +2003,7 @@ describe('AgentChatView server-restart recovery', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } @@ -2039,9 +2068,7 @@ describe('AgentChatView server-restart recovery', () => { function Wrapper() { const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined + const content = root?.type === 'leaf' ? normalizeAgentChatPaneContent(root.content) : undefined if (!content) return null return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> } diff --git a/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx b/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx index f55f59c0e..55e4788d3 100644 --- a/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx +++ b/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx @@ -1,398 +1,248 @@ -import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest' -import { render, screen, cleanup, act, waitFor } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' -import { Provider, useSelector } from 'react-redux' -import AgentChatView from '@/components/agent-chat/AgentChatView' -import agentChatReducer, { markSessionLost, sessionCreated, sessionInit, sessionSnapshotReceived, setSessionStatus } from '@/store/agentChatSlice' +import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' import panesReducer, { initLayout } from '@/store/panesSlice' import settingsReducer from '@/store/settingsSlice' -import type { AgentChatPaneContent } from '@/store/paneTypes' +import freshAgentReducer from '@/store/freshAgentSlice' +import agentChatReducer, { markSessionLost, sessionSnapshotReceived } from '@/store/agentChatSlice' +import { useAppSelector } from '@/store/hooks' import type { PaneNode } from '@/store/paneTypes' -import { buildRestoreError } from '@shared/session-contract' -// jsdom doesn't implement scrollIntoView -beforeAll(() => { - Element.prototype.scrollIntoView = vi.fn() -}) +const wsMock = vi.hoisted(() => ({ + send: vi.fn(), + onMessage: vi.fn(() => () => {}), +})) -const wsSend = vi.fn() -const getAgentTimelinePage = vi.fn() -const setSessionMetadata = vi.fn(() => Promise.resolve(undefined)) +const apiMock = vi.hoisted(() => ({ + getFreshAgentThreadSnapshot: vi.fn(), +})) vi.mock('@/lib/ws-client', () => ({ - getWsClient: () => ({ - send: wsSend, - onReconnect: vi.fn(() => vi.fn()), - }), + getWsClient: () => wsMock, })) vi.mock('@/lib/api', async () => { const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api') return { ...actual, - getAgentTimelinePage: (...args: unknown[]) => getAgentTimelinePage(...args), - setSessionMetadata: (...args: unknown[]) => setSessionMetadata(...args), + getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, } }) -function makeStore() { +function createStore() { return configureStore({ reducer: { - agentChat: agentChatReducer, panes: panesReducer, settings: settingsReducer, + freshAgent: freshAgentReducer, + agentChat: agentChatReducer, + }, + preloadedState: { + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, }, }) } -/** Read pane content from the store for a given tab/pane ID. */ -function getPaneContent(store: ReturnType<typeof makeStore>, tabId: string, paneId: string): AgentChatPaneContent | undefined { - const root = store.getState().panes.layouts[tabId] - if (!root) return undefined - function find(node: PaneNode): AgentChatPaneContent | undefined { - if (node.type === 'leaf' && node.id === paneId && node.content.kind === 'agent-chat') { - return node.content - } - if (node.type === 'split') { - return find(node.children[0]) || find(node.children[1]) +function StoreBackedFreshAgentView({ tabId, paneId }: { tabId: string; paneId: string }) { + const paneContent = useAppSelector((state) => { + const layout = state.panes.layouts[tabId] + if (!layout || layout.type !== 'leaf' || layout.id !== paneId || layout.content.kind !== 'fresh-agent') { + throw new Error(`Missing fresh-agent pane ${paneId}`) } - return undefined - } - return find(root) + return layout.content + }) + return <FreshAgentView tabId={tabId} paneId={paneId} paneContent={paneContent} /> +} + +function leafContent(layout: PaneNode | undefined) { + return layout?.type === 'leaf' ? layout.content : undefined } -describe('AgentChatView — immediate recovery when session is lost', () => { +describe('Fresh-agent lost-session recovery coverage', () => { afterEach(() => { cleanup() - wsSend.mockClear() - getAgentTimelinePage.mockReset() - setSessionMetadata.mockClear() - vi.useRealTimers() }) - it('does not restart from a mutable named resume token when session is marked as lost', async () => { - const store = makeStore() - const pane: AgentChatPaneContent = { - kind: 'agent-chat', provider: 'freshclaude', - createRequestId: 'req-stale', + beforeEach(() => { + wsMock.send.mockReset() + wsMock.onMessage.mockReset() + wsMock.onMessage.mockImplementation(() => () => {}) + apiMock.getFreshAgentThreadSnapshot.mockReset() + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new TypeError('Failed to parse URL from /api/fresh-agent/threads/claude/dead-session-id')) + }) + + it('shows a restoring state for a durable freshclaude resume before recovery completes', async () => { + const durableSessionId = '00000000-0000-4000-8000-000000000123' + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-stale', + sessionId: 'dead-session-id', + status: 'idle', + resumeSessionId: 'named-resume', + }, + })) + store.dispatch(sessionSnapshotReceived({ sessionId: 'dead-session-id', + latestTurnId: 'turn-1', status: 'idle', - resumeSessionId: 'cli-session-to-resume', - } - - store.dispatch(initLayout({ tabId: 't1', content: pane, paneId: 'p1' })) - - // Use a wrapper that reads pane content reactively from the store - function Wrapper() { - const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts['t1']) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined - if (!content) return null - return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> - } + timelineSessionId: durableSessionId, + revision: 2, + })) - render( + const view = render( <Provider store={store}> - <Wrapper /> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> </Provider>, ) - // Initially shows restoring (sessionId exists but no session in Redux yet) - expect(screen.getByText(/restoring/i)).toBeInTheDocument() - - wsSend.mockClear() - - // Simulate server responding to sdk.attach with INVALID_SESSION_ID: - // The sdk-message-handler dispatches markSessionLost which creates the - // session entry with lost=true and historyLoaded=true. - act(() => { - store.dispatch(markSessionLost({ sessionId: 'dead-session-id' })) - }) - - // The dead live SDK session should be cleared, but the client must not - // restart from a mutable named resume token once no canonical durable id exists. await waitFor(() => { - const content = getPaneContent(store, 't1', 'p1') - expect(content).toBeDefined() - expect(content!.sessionId).toBeUndefined() + expect(within(view.container).getAllByText(/restoring/i).length).toBeGreaterThan(0) }) - - const content = getPaneContent(store, 't1', 'p1')! - expect(content.status).toBe('idle') - expect(content.restoreError).toEqual(buildRestoreError('dead_live_handle')) - // The pane may still carry the original mutable name for display, but it - // must not be used as a restore target. - expect(content.resumeSessionId).toBe('cli-session-to-resume') - const createCalls = wsSend.mock.calls.filter( - (c: any[]) => c[0]?.type === 'sdk.create', - ) - expect(createCalls).toHaveLength(0) + expect(within(view.container).queryByText(/failed to parse url/i)).not.toBeInTheDocument() }) - it('recovers with timelineSessionId from sdk.session.snapshot even when the session is marked lost before sdk.session.init', async () => { - const canonicalSessionId = '00000000-0000-4000-8000-000000000211' - let resolveTimelinePage: ((value: { - sessionId: string - items: Array<Record<string, unknown>> - nextCursor: null - revision: number - bodies: Record<string, unknown> - }) => void) | undefined - getAgentTimelinePage.mockImplementationOnce(() => new Promise((resolve) => { - resolveTimelinePage = resolve + it('recreates a lost freshclaude session with the canonical durable resume id', async () => { + const durableSessionId = '00000000-0000-4000-8000-000000000123' + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-stale', + sessionId: 'dead-session-id', + status: 'idle', + resumeSessionId: 'named-resume', + }, })) - - const store = makeStore() - const pane = { - kind: 'agent-chat', - provider: 'freshclaude', - createRequestId: 'req-stale', - sessionId: 'sdk-stale-1', + store.dispatch(sessionSnapshotReceived({ + sessionId: 'dead-session-id', + latestTurnId: 'turn-1', status: 'idle', - resumeSessionId: 'named-resume', - } satisfies AgentChatPaneContent - - store.dispatch(initLayout({ tabId: 't1', paneId: 'p1', content: pane })) - - function Wrapper() { - const root = useSelector((s: ReturnType<typeof store.getState>) => s.panes.layouts.t1) - const content = root?.type === 'leaf' && root.content.kind === 'agent-chat' - ? root.content - : undefined - if (!content) return null - return <AgentChatView tabId="t1" paneId="p1" paneContent={content} /> - } + timelineSessionId: durableSessionId, + revision: 2, + })) render( <Provider store={store}> - <Wrapper /> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> </Provider>, ) - act(() => { - store.dispatch(sessionSnapshotReceived({ - sessionId: 'sdk-stale-1', - latestTurnId: 'turn-2', - status: 'idle', - timelineSessionId: canonicalSessionId, - revision: 2, - })) - store.dispatch(markSessionLost({ sessionId: 'sdk-stale-1' })) - }) - await waitFor(() => { - expect(getAgentTimelinePage).toHaveBeenCalledWith( - canonicalSessionId, - expect.objectContaining({ priority: 'visible', revision: 2, includeBodies: true }), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ) + expect(screen.getAllByText(/restoring/i).length).toBeGreaterThan(0) }) - expect(wsSend.mock.calls.some((call: any[]) => call[0]?.type === 'sdk.create')).toBe(false) - - await act(async () => { - resolveTimelinePage?.({ - sessionId: canonicalSessionId, - items: [ - { - turnId: 'turn-2', - sessionId: canonicalSessionId, - role: 'assistant', - summary: 'Recovered answer', - timestamp: '2026-03-10T10:00:20.000Z', - }, - ], - nextCursor: null, - revision: 2, - bodies: { - 'turn-2': { - sessionId: canonicalSessionId, - turnId: 'turn-2', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Recovered durable answer' }], - timestamp: '2026-03-10T10:00:20.000Z', - }, - }, - }, - }) - await Promise.resolve() + act(() => { + store.dispatch(markSessionLost({ sessionId: 'dead-session-id' })) }) await waitFor(() => { - const createCalls = wsSend.mock.calls.filter((call: any[]) => call[0]?.type === 'sdk.create') - expect(createCalls.at(-1)?.[0]?.resumeSessionId).toBe(canonicalSessionId) + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + sessionType: 'freshclaude', + resumeSessionId: durableSessionId, + })) }) - }) -}) - -describe('AgentChatView — remount resilience (split pane bug)', () => { - afterEach(() => { - cleanup() - wsSend.mockClear() - vi.useRealTimers() - }) - - it('does not get stuck after remount when session is already established', () => { - const store = makeStore() - const pane: AgentChatPaneContent = { - kind: 'agent-chat', provider: 'freshclaude', - createRequestId: 'req-1', - sessionId: 'sess-1', - status: 'idle', - } - - // Pre-populate the Redux session as if sdk.created + sdk.session.init already happened - store.dispatch(sessionCreated({ requestId: 'req-1', sessionId: 'sess-1' })) - store.dispatch(sessionInit({ - sessionId: 'sess-1', - cliSessionId: 'cli-abc', - model: 'claude-opus-4-6', + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + resumeSessionId: 'named-resume', })) - - store.dispatch(initLayout({ tabId: 't1', content: pane, paneId: 'p1' })) - - // First mount (simulating the original render) - const { unmount } = render( - <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> - </Provider>, - ) - - // Should NOT show restoring — session is already established with historyLoaded=true - expect(screen.queryByText(/restoring/i)).not.toBeInTheDocument() - - // Composer should be interactive (not "Waiting for connection") - const textarea = screen.getByRole('textbox') - expect(textarea).not.toBeDisabled() - - // Now simulate unmount + remount (what happens during split) - unmount() - wsSend.mockClear() - - render( - <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> - </Provider>, - ) - - // After remount, should still NOT show restoring - expect(screen.queryByText(/restoring/i)).not.toBeInTheDocument() - - // Composer should still be interactive - const textarea2 = screen.getByRole('textbox') - expect(textarea2).not.toBeDisabled() - - // Should NOT send sdk.attach — session is already hydrated and WS - // subscription is connection-scoped, so it survives the remount. - const attachCalls = wsSend.mock.calls.filter( - (c: any[]) => c[0]?.type === 'sdk.attach', - ) - expect(attachCalls).toHaveLength(0) - - // Should NOT have sent sdk.create - const createCalls = wsSend.mock.calls.filter( - (c: any[]) => c[0]?.type === 'sdk.create', - ) - expect(createCalls).toHaveLength(0) }) - it('pane status remains interactive after remount (not reset to starting)', () => { - const store = makeStore() - const pane: AgentChatPaneContent = { - kind: 'agent-chat', provider: 'freshclaude', - createRequestId: 'req-1', - sessionId: 'sess-1', - status: 'connected', - } - - store.dispatch(sessionCreated({ requestId: 'req-1', sessionId: 'sess-1' })) - store.dispatch(sessionInit({ - sessionId: 'sess-1', - cliSessionId: 'cli-abc', - model: 'claude-opus-4-6', + it('does not recreate from a named-only legacy resume target', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-named-only', + sessionId: 'dead-session-named', + status: 'idle', + resumeSessionId: 'named-only-fallback', + }, })) - store.dispatch(setSessionStatus({ sessionId: 'sess-1', status: 'connected' })) - - store.dispatch(initLayout({ tabId: 't1', content: pane, paneId: 'p1' })) - - // Simulate unmount + remount - const { unmount } = render( - <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> - </Provider>, - ) - unmount() + store.dispatch(markSessionLost({ sessionId: 'dead-session-named' })) render( <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> </Provider>, ) - // Status bar should show "Connected", not "Starting Claude Code..." - expect(screen.getByText('Connected')).toBeInTheDocument() - expect(screen.queryByText(/starting/i)).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText(/legacy name, not a canonical Claude session id/i)).toBeInTheDocument() + }) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + })) + expect(leafContent(store.getState().panes.layouts['tab-1'])?.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'invalid_legacy_restore_target', + }) }) - it('does not regress to "starting" when sdk.status arrives after remount for a still-initializing session', () => { - const store = makeStore() - const pane: AgentChatPaneContent = { - kind: 'agent-chat', provider: 'freshclaude', - createRequestId: 'req-1', - sessionId: 'sess-1', - status: 'starting', - } - - // Session just created — still in 'starting' status, not yet 'connected' - store.dispatch(sessionCreated({ requestId: 'req-1', sessionId: 'sess-1' })) - - store.dispatch(initLayout({ tabId: 't1', content: pane, paneId: 'p1' })) - - // First mount - const { unmount } = render( - <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> - </Provider>, - ) - - // Simulate unmount + remount (what happens during split) - unmount() - wsSend.mockClear() + it('writes canonical sessionRef when Claude durable id appears after recovery', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-canonical-id-recovery', + sessionId: 'dead-session-3', + status: 'idle', + resumeSessionId: 'named-resume-alt', + }, + })) + store.dispatch(sessionSnapshotReceived({ + sessionId: 'dead-session-3', + latestTurnId: 'turn-1', + status: 'idle', + timelineSessionId: '00000000-0000-4000-8000-000000000555', + revision: 2, + })) + store.dispatch(markSessionLost({ sessionId: 'dead-session-3' })) render( <Provider store={store}> - <AgentChatView tabId="t1" paneId="p1" paneContent={pane} /> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> </Provider>, ) - // Session is hydrated (historyLoaded=true from fresh create) so - // sdk.attach is skipped — WS subscription survives the remount. - const attachCalls = wsSend.mock.calls.filter( - (c: any[]) => c[0]?.type === 'sdk.attach', - ) - expect(attachCalls).toHaveLength(0) - - // Simulate server status arriving (e.g. from the live subscription): - act(() => { - store.dispatch(setSessionStatus({ sessionId: 'sess-1', status: 'starting' })) - }) - - // Pane should still show "Starting Claude Code..." — that's fine for now - // The key thing is: when the session later transitions to 'connected', - // the pane should update accordingly - act(() => { - store.dispatch(sessionInit({ - sessionId: 'sess-1', - cliSessionId: 'cli-abc', - model: 'claude-opus-4-6', + await waitFor(() => { + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + resumeSessionId: '00000000-0000-4000-8000-000000000555', })) - store.dispatch(setSessionStatus({ sessionId: 'sess-1', status: 'connected' })) }) - - // Now the status should have progressed — no longer stuck on 'starting' - const content = getPaneContent(store, 't1', 'p1') - expect(content!.status).toBe('connected') + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + resumeSessionId: 'named-resume-alt', + })) }) }) diff --git a/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx b/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx index 79751321a..4fdfe3783 100644 --- a/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx +++ b/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx @@ -21,7 +21,7 @@ import agentChatReducer, { } from '@/store/agentChatSlice' import panesReducer, { initLayout, addPane } from '@/store/panesSlice' import settingsReducer from '@/store/settingsSlice' -import type { AgentChatPaneContent } from '@/store/paneTypes' +import type { AgentChatPaneContent, FreshAgentPaneContent, PaneContent } from '@/store/paneTypes' import type { PaneNode } from '@/store/paneTypes' // jsdom doesn't implement scrollIntoView @@ -122,8 +122,20 @@ function getPaneContent(store: ReturnType<typeof makeStore>, tabId: string, pane const root = store.getState().panes.layouts[tabId] if (!root) return undefined const leaf = findLeaf(root, paneId) - if (leaf && leaf.content.kind === 'agent-chat') return leaf.content - return undefined + return normalizeAgentChatPaneContent(leaf?.content) +} + +function normalizeAgentChatPaneContent(content: PaneContent | undefined): AgentChatPaneContent | undefined { + if (!content) return undefined + if (content.kind === 'agent-chat') return content + if (content.kind !== 'fresh-agent') return undefined + if (content.sessionType !== 'freshclaude' && content.sessionType !== 'kilroy') return undefined + const migrated: FreshAgentPaneContent = content + return { + ...migrated, + kind: 'agent-chat', + provider: migrated.sessionType, + } } /** @@ -135,12 +147,13 @@ function ReactiveWrapper({ store, tabId, paneId }: { tabId: string paneId: string }) { - const content = useSelector((s: ReturnType<typeof store.getState>) => { + const rawContent = useSelector((s: ReturnType<typeof store.getState>) => { const root = s.panes.layouts[tabId] if (!root) return undefined const leaf = findLeaf(root, paneId) - return leaf?.content.kind === 'agent-chat' ? leaf.content : undefined + return leaf?.content }) + const content = normalizeAgentChatPaneContent(rawContent) if (!content) return <div data-testid="no-content">No content</div> return <AgentChatView tabId={tabId} paneId={paneId} paneContent={content} /> } @@ -386,7 +399,7 @@ describe('AgentChatView — split pane (Bug 2)', () => { await waitFor(() => { expect(getAgentTimelinePage).toHaveBeenCalledWith( - 'sess-1', + 'cli-abc', expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 2 }), expect.objectContaining({ signal: expect.any(AbortSignal) }), ) @@ -709,7 +722,7 @@ describe('AgentChatView — split pane (Bug 2)', () => { await waitFor(() => { expect(getAgentTimelinePage).toHaveBeenCalledWith( - 'sess-1', + 'cli-abc', expect.objectContaining({ priority: 'visible', includeBodies: true, revision: 2 }), expect.objectContaining({ signal: expect.any(AbortSignal) }), ) @@ -723,7 +736,7 @@ describe('AgentChatView — split pane (Bug 2)', () => { await waitFor(() => { expect(getAgentTurnBody).toHaveBeenCalledWith( - 'sess-1', + 'cli-abc', 'turn-2', expect.objectContaining({ signal: expect.any(AbortSignal), revision: 2 }), ) diff --git a/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx new file mode 100644 index 000000000..828d40956 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { FreshAgentDiffPanel } from '@/components/fresh-agent/FreshAgentDiffPanel' + +describe('FreshAgentDiffPanel', () => { + it('renders diff entries', () => { + render(<FreshAgentDiffPanel diffs={[{ id: 'diff-1', title: 'src/app.tsx' }]} />) + expect(screen.getByText('Diffs')).toBeInTheDocument() + expect(screen.getByText('src/app.tsx')).toBeInTheDocument() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx new file mode 100644 index 000000000..d505c39e2 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { FreshAgentTranscript } from '@/components/fresh-agent/FreshAgentTranscript' + +describe('FreshAgentTranscript', () => { + it('renders normalized text turns', () => { + render( + <FreshAgentTranscript + turns={[ + { + id: 'turn-1', + role: 'assistant', + items: [{ id: 'item-1', kind: 'text', text: 'Hello from Fresh Agent' }], + }, + ]} + />, + ) + + expect(screen.getByText('Assistant')).toBeInTheDocument() + expect(screen.getByText('Hello from Fresh Agent')).toBeInTheDocument() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx new file mode 100644 index 000000000..cdd7a0372 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -0,0 +1,669 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import panesReducer from '@/store/panesSlice' +import settingsReducer from '@/store/settingsSlice' +import freshAgentReducer from '@/store/freshAgentSlice' +import agentChatReducer from '@/store/agentChatSlice' +import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' +import { initLayout } from '@/store/panesSlice' +import { useAppSelector } from '@/store/hooks' +import { sessionInit, setSessionStatus } from '@/store/agentChatSlice' + +const wsMock = vi.hoisted(() => ({ + send: vi.fn(), + onMessage: vi.fn(() => () => {}), +})) + +const apiMock = vi.hoisted(() => ({ + getFreshAgentThreadSnapshot: vi.fn(), +})) + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMock, +})) + +vi.mock('@/components/agent-chat/AgentChatView', () => ({ + default: ({ paneContent }: { paneContent: { provider: string } }) => <div>agent:{paneContent.provider}</div>, +})) + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api') + return { + ...actual, + getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, + } +}) + +function createStore() { + return configureStore({ + reducer: { + panes: panesReducer, + settings: settingsReducer, + freshAgent: freshAgentReducer, + agentChat: agentChatReducer, + }, + preloadedState: { + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + }, + }) +} + +function StoreBackedFreshAgentView({ + tabId, + paneId, +}: { + tabId: string + paneId: string +}) { + const paneContent = useAppSelector((state) => { + const layout = state.panes.layouts[tabId] + if (!layout || layout.type !== 'leaf' || layout.id !== paneId || layout.content.kind !== 'fresh-agent') { + throw new Error(`Missing fresh-agent pane ${paneId}`) + } + return layout.content + }) + return <FreshAgentView tabId={tabId} paneId={paneId} paneContent={paneContent} /> +} + +beforeEach(() => { + wsMock.send.mockReset() + wsMock.onMessage.mockReset() + wsMock.onMessage.mockImplementation(() => () => {}) + apiMock.getFreshAgentThreadSnapshot.mockReset() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', + summary: 'Codex summary', + capabilities: { send: true, interrupt: true, fork: true }, + diffs: [{ id: 'diff-1', title: 'README.md' }], + worktrees: [{ id: 'wt-1', path: '/tmp/worktree', branch: 'feature/x' }], + turns: [{ id: 'turn-1', role: 'assistant', items: [{ id: 'item-1', kind: 'text', text: 'Codex turn' }] }], + }) +}) + +afterEach(() => { + cleanup() +}) + +describe('FreshAgentView', () => { + it('renders freshclaude in the shared shell and answers approvals/questions over fresh-agent WS', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ + status: 'running', + summary: 'Claude summary', + capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: false }, + pendingApprovals: [{ + requestId: 'approval-1', + toolName: 'Bash', + input: { command: 'echo hello-from-fresh-agent' }, + }], + pendingQuestions: [{ + requestId: 'question-1', + questions: [{ + header: 'Approve plan', + question: 'How should Claude proceed?', + options: [ + { label: 'Continue', description: 'Keep going' }, + { label: 'Stop', description: 'Pause the task' }, + ], + multiSelect: false, + }], + }], + turns: [], + }) + + render( + <Provider store={store}> + <FreshAgentView + tabId="tab-1" + paneId="pane-1" + paneContent={{ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + sessionId: 'claude-thread-1', + status: 'connected', + }} + /> + </Provider>, + ) + + await waitFor(() => { + expect(screen.getByText('Claude summary')).toBeInTheDocument() + }) + expect(screen.queryByText('agent:freshclaude')).not.toBeInTheDocument() + + const permissionBanner = screen.getByRole('alert', { name: /permission request for bash/i }) + expect(permissionBanner).toHaveTextContent('echo hello-from-fresh-agent') + fireEvent.click(screen.getByRole('button', { name: /allow tool use/i })) + + const questionBanner = screen.getByRole('region', { name: /question from claude/i }) + expect(questionBanner).toHaveTextContent('How should Claude proceed?') + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.approval.respond', + sessionId: 'claude-thread-1', + sessionType: 'freshclaude', + provider: 'claude', + requestId: 'approval-1', + decision: { behavior: 'allow', updatedInput: {} }, + }) + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.question.respond', + sessionId: 'claude-thread-1', + sessionType: 'freshclaude', + provider: 'claude', + requestId: 'question-1', + answers: { 'How should Claude proceed?': 'Continue' }, + }) + }) + + it('renders Codex review and fork metadata in the shared shell', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ + status: 'running', + summary: 'Codex summary', + capabilities: { send: false, interrupt: false, questions: true, fork: false }, + pendingQuestions: [{ + requestId: 'question-codex', + questions: [{ + header: 'Choose path', + question: 'How should Codex continue?', + options: [ + { label: 'Patch', description: 'Apply the diff' }, + { label: 'Explain', description: 'Describe the change' }, + ], + multiSelect: false, + }], + }], + diffs: [{ id: 'diff-1', title: 'README.md' }], + worktrees: [{ id: 'wt-1', path: '/tmp/worktree', branch: 'feature/x' }], + extensions: { + codex: { + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }, + }, + turns: [{ id: 'turn-1', role: 'assistant', items: [{ id: 'item-1', kind: 'text', text: 'Codex turn' }] }], + }) + + render( + <Provider store={store}> + <FreshAgentView + tabId="tab-1" + paneId="pane-1" + paneContent={{ + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-2', + sessionId: 'thread-1', + status: 'connected', + }} + /> + </Provider>, + ) + + await waitFor(() => { + expect(screen.getByText('Codex summary')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Fork' })).toBeDisabled() + expect(screen.getByText('README.md')).toBeInTheDocument() + expect(screen.getByText(/feature\/x/)).toBeInTheDocument() + expect(screen.getByText('Review')).toBeInTheDocument() + expect(screen.getByText('review-1')).toBeInTheDocument() + expect(screen.getByText('pending')).toBeInTheDocument() + expect(screen.getByText('Fork lineage')).toBeInTheDocument() + expect(screen.getByText('thread-parent-1')).toBeInTheDocument() + expect(screen.getByRole('region', { name: /question from codex/i })).toHaveTextContent('Codex has a question') + }) + + it('acquires a session id for a new non-Claude fresh-agent pane after freshAgent.created', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-create', + status: 'creating', + }, + })) + + render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + requestId: 'req-create', + sessionType: 'freshcodex', + provider: 'codex', + })) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + expect(onMessage).toBeTypeOf('function') + onMessage({ + type: 'freshAgent.created', + requestId: 'req-create', + sessionId: 'thread-created', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + }) + + await waitFor(() => { + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex', 'codex', 'thread-created', expect.any(Object)) + }) + }) + + it('sends, interrupts, and forks through fresh-agent WS actions when the capability is available', async () => { + const store = createStore() + render( + <Provider store={store}> + <FreshAgentView + tabId="tab-1" + paneId="pane-1" + paneContent={{ + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-2', + sessionId: 'thread-1', + status: 'running', + }} + /> + </Provider>, + ) + + await waitFor(() => { + expect(screen.getByText('Codex summary')).toBeInTheDocument() + }) + + wsMock.send.mockClear() + + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Ship it' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.send', + sessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Ship it', + }) + + fireEvent.click(screen.getByRole('button', { name: 'Interrupt' })) + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.interrupt', + sessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + }) + + fireEvent.click(screen.getByRole('button', { name: 'Fork' })) + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.fork', + sessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + }) + }) + + it('keeps an established freshclaude pane interactive after remount when snapshot loading is unavailable', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new TypeError('Failed to parse URL from /api/fresh-agent/threads/claude/sess-1')) + store.dispatch(sessionInit({ + sessionId: 'sess-1', + cliSessionId: 'cli-abc', + model: 'claude-opus-4-6', + })) + store.dispatch(setSessionStatus({ sessionId: 'sess-1', status: 'idle' })) + + const paneContent = { + kind: 'fresh-agent' as const, + sessionType: 'freshclaude' as const, + provider: 'claude' as const, + createRequestId: 'req-remount', + sessionId: 'sess-1', + status: 'idle' as const, + resumeSessionId: 'cli-abc', + } + + const { unmount } = render( + <Provider store={store}> + <FreshAgentView tabId="tab-1" paneId="pane-1" paneContent={paneContent} /> + </Provider>, + ) + + await waitFor(() => { + expect(screen.getByText('Ready')).toBeInTheDocument() + }) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + expect(screen.queryByText(/failed to parse url/i)).not.toBeInTheDocument() + + unmount() + wsMock.send.mockClear() + + render( + <Provider store={store}> + <FreshAgentView tabId="tab-1" paneId="pane-1" paneContent={paneContent} /> + </Provider>, + ) + + await waitFor(() => { + expect(screen.getByText('Ready')).toBeInTheDocument() + }) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.create' })) + expect(screen.queryByText(/failed to parse url/i)).not.toBeInTheDocument() + }) + + it('recreates a lost freshclaude session through fresh-agent transport events with the durable resume id', async () => { + const store = createStore() + const durableSessionId = '00000000-0000-4000-8000-000000000441' + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new TypeError('Failed to parse URL from /api/fresh-agent/threads/claude/dead-session-id')) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-lost', + sessionId: 'dead-session-id', + status: 'idle', + resumeSessionId: 'named-resume', + }, + })) + + render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + expect(onMessage).toBeTypeOf('function') + + act(() => { + onMessage({ + type: 'freshAgent.event', + sessionId: 'dead-session-id', + sessionType: 'freshclaude', + provider: 'claude', + event: { + type: 'sdk.session.snapshot', + sessionId: 'dead-session-id', + latestTurnId: 'turn-1', + status: 'idle', + timelineSessionId: durableSessionId, + revision: 2, + }, + }) + }) + + await waitFor(() => { + expect(screen.getAllByText(/restoring/i).length).toBeGreaterThan(0) + }) + expect(screen.queryByText(/failed to parse url/i)).not.toBeInTheDocument() + + act(() => { + onMessage({ + type: 'freshAgent.event', + sessionId: 'dead-session-id', + sessionType: 'freshclaude', + provider: 'claude', + event: { + type: 'sdk.error', + sessionId: 'dead-session-id', + code: 'INVALID_SESSION_ID', + message: 'Session no longer exists', + }, + }) + }) + + await waitFor(() => { + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: durableSessionId, + })) + }) + }) + + it('shows the underlying snapshot-load error when a freshclaude restore has no session-state failure message', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockRejectedValueOnce(new Error('Stale restore revision')) + + render( + <Provider store={store}> + <FreshAgentView + tabId="tab-1" + paneId="pane-1" + paneContent={{ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-error', + sessionId: 'claude-thread-restore', + status: 'idle', + resumeSessionId: 'claude-thread-restore', + }} + /> + </Provider>, + ) + + expect(await screen.findByText('Stale restore revision')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('Stale restore revision') + }) + + it('renders restoreError pane and suppresses automatic freshAgent.create', () => { + const store = createStore() + render( + <Provider store={store}> + <FreshAgentView + tabId="tab-1" + paneId="pane-1" + paneContent={{ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-restore-error', + status: 'create-failed', + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' }, + }} + /> + </Provider>, + ) + + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.create' })) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.attach' })) + }) + + it('recovers using sessionRef.sessionId for a pane with only sessionRef', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-sessionref-only', + status: 'creating', + sessionRef: { provider: 'codex', sessionId: 'codex-thread-recover' }, + }, + })) + + const { unmount } = render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.create', + requestId: 'req-sessionref-only', + sessionRef: { provider: 'codex', sessionId: 'codex-thread-recover' }, + })) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + onMessage({ + type: 'freshAgent.created', + requestId: 'req-sessionref-only', + sessionId: 'created-thread-456', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + sessionRef: { provider: 'codex', sessionId: 'codex-thread-recover' }, + }) + + await waitFor(() => { + const state = store.getState() + const leaf = state.panes.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content.sessionRef).toEqual({ provider: 'codex', sessionId: 'codex-thread-recover' }) + expect(leaf.content.sessionId).toBe('created-thread-456') + expect(leaf.content.status).toBe('connected') + }) + unmount() + }) + + it('clears stale restoreError when a valid sessionRef appears', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-clear-error', + status: 'creating', + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' }, + sessionRef: { provider: 'codex', sessionId: 'codex-durable-id' }, + }, + })) + + render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + onMessage({ + type: 'freshAgent.created', + requestId: 'req-clear-error', + sessionId: 'created-789', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + sessionRef: { provider: 'codex', sessionId: 'codex-durable-id' }, + }) + + await waitFor(() => { + const state = store.getState() + const leaf = state.panes.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content.sessionRef).toEqual({ provider: 'codex', sessionId: 'codex-durable-id' }) + expect(leaf.content.restoreError).toBeUndefined() + }) + }) + + it('freshAgent.created does not write sessionRef for Claude when message has no sessionRef', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-claude-noref', + status: 'creating', + }, + })) + + render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + onMessage({ + type: 'freshAgent.created', + requestId: 'req-claude-noref', + sessionId: 'runtime-sdk-session-id', + sessionType: 'freshclaude', + provider: 'claude', + runtimeProvider: 'claude', + }) + + await waitFor(() => { + const state = store.getState() + const leaf = state.panes.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content.sessionId).toBe('runtime-sdk-session-id') + expect(leaf.content.sessionRef).toBeUndefined() + }) + }) + + it('does not clobber newer modelSelection when freshAgent.created arrives late', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-late-created', + status: 'creating', + modelSelection: { kind: 'exact', modelId: 'ui-selected-model' }, + }, + })) + + render( + <Provider store={store}> + <StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" /> + </Provider>, + ) + + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + // Simulate a late arriving created message that represents a much older snapshot + onMessage({ + type: 'freshAgent.created', + requestId: 'req-late-created', + sessionId: 'runtime-id', + sessionType: 'freshclaude', + provider: 'claude', + runtimeProvider: 'claude', + }) + + await waitFor(() => { + const state = store.getState() + const leaf = state.panes.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content.sessionId).toBe('runtime-id') + expect(leaf.content.modelSelection).toEqual({ kind: 'exact', modelId: 'ui-selected-model' }) + }) + }) +}) diff --git a/test/unit/client/components/icons/PaneIcon.test.tsx b/test/unit/client/components/icons/PaneIcon.test.tsx index 1b7fd91e5..deb9ef438 100644 --- a/test/unit/client/components/icons/PaneIcon.test.tsx +++ b/test/unit/client/components/icons/PaneIcon.test.tsx @@ -88,8 +88,8 @@ describe('PaneIcon', () => { expect(screen.getByTestId('file-text-icon')).toBeInTheDocument() }) - it('renders freshclaude icon for agent-chat panes', () => { - render( + it('renders an icon for freshclaude agent-chat panes', () => { + const { container } = render( <PaneIcon content={{ kind: 'agent-chat', provider: 'freshclaude', @@ -98,11 +98,11 @@ describe('PaneIcon', () => { }} /> ) - expect(screen.getByTestId('freshclaude-icon')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() }) - it('renders kilroy icon for kilroy agent-chat panes', () => { - render( + it('renders an icon for kilroy agent-chat panes', () => { + const { container } = render( <PaneIcon content={{ kind: 'agent-chat', provider: 'kilroy', @@ -111,7 +111,7 @@ describe('PaneIcon', () => { }} /> ) - expect(screen.getByTestId('kilroy-icon')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() }) it('renders layout-grid icon for picker panes', () => { diff --git a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx index 214829020..ed7457a53 100644 --- a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx @@ -270,6 +270,43 @@ describe('createContentForType with ext: prefix', () => { } }) + it('creates fresh-agent content for freshclaude selections', async () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { + status: 'ready', + platform: 'linux', + availableClis: { claude: true }, + }, + ) + + render( + <Provider store={store}> + <PaneContainer tabId="tab-1" node={node} /> + </Provider>, + ) + + const container = getPickerContainer() + fireEvent.keyDown(container, { key: 'a' }) + fireEvent.transitionEnd(container) + const input = screen.getByLabelText('Starting directory for Freshclaude') + fireEvent.change(input, { target: { value: '/workspace/project' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => { + const state = store.getState().panes + const paneContent = (state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }>).content + expect(paneContent).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) + }) + it('does not include cwd or createRequestId in extension content', () => { const extension: ClientExtensionEntry = { name: 'simple-ext', @@ -349,9 +386,10 @@ describe('createContentForType with ext: prefix', () => { await waitFor(() => { const state = store.getState().panes const paneContent = (state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }>).content - expect(paneContent.kind).toBe('agent-chat') - if (paneContent.kind === 'agent-chat') { - expect(paneContent.provider).toBe('freshclaude') + expect(paneContent.kind).toBe('fresh-agent') + if (paneContent.kind === 'fresh-agent') { + expect(paneContent.sessionType).toBe('freshclaude') + expect(paneContent.provider).toBe('claude') expect(paneContent.plugins).toEqual(['planner', 'sandbox']) expect(paneContent.modelSelection).toEqual({ kind: 'tracked', modelId: 'opus[1m]' }) expect(paneContent.permissionMode).toBe('default') diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 8fcdde4b8..db3f8a27b 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -37,6 +37,7 @@ const { mockSend, mockTerminalView, mockAgentChatView, + mockFreshAgentView, mockBrowserPane, browserPaneMounts, browserPaneUnmounts, @@ -44,6 +45,8 @@ const { mockApiPost, mockApiPatch, saveServerSettingsPatchSpy, + cancelCreateSpy, + cancelWsCreateSpy, } = vi.hoisted(() => ({ mockSend: vi.fn(), mockTerminalView: vi.fn(({ tabId, paneId, hidden }: { tabId: string; paneId: string; hidden?: boolean }) => ( @@ -52,6 +55,9 @@ const { mockAgentChatView: vi.fn(({ paneId }: { paneId: string }) => ( <div data-testid={`agent-chat-${paneId}`}>Agent Chat</div> )), + mockFreshAgentView: vi.fn(({ paneId }: { paneId: string }) => ( + <div data-testid={`fresh-agent-${paneId}`}>Fresh Agent</div> + )), mockBrowserPane: vi.fn(), browserPaneMounts: [] as string[], browserPaneUnmounts: [] as string[], @@ -62,12 +68,16 @@ const { type: 'settings/saveServerSettingsPatch', payload: patch, })), + cancelCreateSpy: vi.fn(), + cancelWsCreateSpy: vi.fn(), })) // Mock the ws-client module vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: mockSend, + cancelCreate: cancelWsCreateSpy, + onMessage: () => () => {}, }), })) @@ -77,12 +87,22 @@ vi.mock('@/lib/api', () => ({ post: (path: string, body: unknown) => mockApiPost(path, body), patch: (path: string, body: unknown) => mockApiPatch(path, body), }, + getFreshAgentThreadSnapshot: vi.fn().mockResolvedValue({ + status: 'idle', + summary: 'Fresh agent test snapshot', + capabilities: { send: false, interrupt: false, fork: false }, + turns: [], + }), })) vi.mock('@/store/settingsThunks', () => ({ saveServerSettingsPatch: (patch: unknown) => saveServerSettingsPatchSpy(patch), })) +vi.mock('@/lib/sdk-message-handler', () => ({ + cancelCreate: (requestId: string) => cancelCreateSpy(requestId), +})) + // Mock lucide-react icons vi.mock('lucide-react', () => ({ X: ({ className }: { className?: string }) => ( @@ -165,6 +185,10 @@ vi.mock('@/components/agent-chat/AgentChatView', () => ({ default: mockAgentChatView, })) +vi.mock('@/components/fresh-agent/FreshAgentView', () => ({ + default: mockFreshAgentView, +})) + // Mock BrowserPane component vi.mock('@/components/panes/BrowserPane', () => ({ default: ({ paneId, url, browserInstanceId }: { paneId: string; url: string; browserInstanceId: string }) => { @@ -300,6 +324,7 @@ describe('PaneContainer', () => { mockSend.mockClear() mockTerminalView.mockClear() mockAgentChatView.mockClear() + mockFreshAgentView.mockClear() mockBrowserPane.mockClear() browserPaneMounts.length = 0 browserPaneUnmounts.length = 0 @@ -307,6 +332,8 @@ describe('PaneContainer', () => { mockApiPost.mockReset() mockApiPatch.mockReset() saveServerSettingsPatchSpy.mockClear() + cancelCreateSpy.mockClear() + cancelWsCreateSpy.mockClear() mockApiGet.mockResolvedValue({ directories: [] }) mockApiPost.mockResolvedValue({ valid: true, resolvedPath: '/resolved/path' }) mockApiPatch.mockResolvedValue({}) @@ -810,6 +837,47 @@ describe('PaneContainer', () => { expect(store.getState().agentChat.pendingCreates['req-1']).toBeUndefined() }) + it('cancels pending agent-chat socket create tracking when closing before sdk.created arrives', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-agent-pending', + content: { + kind: 'agent-chat', + provider: 'freshclaude', + createRequestId: 'req-agent-pending', + status: 'starting', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-agent-pending' }, + }, + {}, + {}, + { + pendingCreates: { + 'req-agent-pending': { + sessionId: undefined, + expectsHistoryHydration: false, + }, + }, + } as Partial<AgentChatState>, + ) + + renderWithStore( + <PaneContainer tabId="tab-1" node={node} />, + store, + ) + + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + + expect(cancelCreateSpy).toHaveBeenCalledWith('req-agent-pending') + expect(cancelWsCreateSpy).toHaveBeenCalledWith('req-agent-pending') + expect(mockSend).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'sdk.kill' })) + }) + it('closes second pane when its close button is clicked', () => { const pane1Id = 'pane-1' const pane2Id = 'pane-2' @@ -853,6 +921,84 @@ describe('PaneContainer', () => { expect((state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }>).id).toBe(pane1Id) }) + it('sends freshAgent.kill when a fresh-agent pane is closed', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-fresh-agent', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-fresh-close', + sessionId: 'thread-codex-1', + status: 'connected', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-fresh-agent' }, + }, + ) + + renderWithStore( + <PaneContainer tabId="tab-1" node={node} />, + store, + ) + + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + + expect(mockSend).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'thread-codex-1', + sessionType: 'freshcodex', + provider: 'codex', + }) + }) + + it('cancels a pending fresh-agent create when the pane closes before session creation finishes', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-fresh-agent-pending', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-fresh-pending', + status: 'creating', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-fresh-agent-pending' }, + }, + {}, + {}, + { + pendingCreates: { + 'req-fresh-pending': { + sessionId: undefined, + expectsHistoryHydration: false, + }, + }, + } as Partial<AgentChatState>, + ) + + renderWithStore( + <PaneContainer tabId="tab-1" node={node} />, + store, + ) + + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + + expect(cancelCreateSpy).toHaveBeenCalledWith('req-fresh-pending') + expect(cancelWsCreateSpy).toHaveBeenCalledWith('req-fresh-pending') + expect(mockSend).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.kill' })) + }) + it('updates active pane when closing the active pane', () => { const pane1Id = 'pane-1' const pane2Id = 'pane-2' @@ -2269,6 +2415,70 @@ describe('PaneContainer', () => { expect(screen.getByText(/freshell \(main\)\s+25%/)).toBeInTheDocument() }) + it('resolves Claude-backed runtime metadata for fresh-agent kilroy panes', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-kilroy-fresh', + content: { + kind: 'fresh-agent', + provider: 'claude', + sessionType: 'kilroy', + createRequestId: 'req-kilroy-fresh', + status: 'starting', + resumeSessionId: 'kilroy-session-restored', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-kilroy-fresh' }, + }, + {}, + { + projects: [ + { + projectPath: '/home/user/code/freshell', + sessions: [ + { + provider: 'claude', + sessionType: 'kilroy', + sessionId: 'kilroy-session-restored', + projectPath: '/home/user/code/freshell', + cwd: '/home/user/code/freshell/.worktrees/issue-163', + gitBranch: 'main', + isDirty: false, + lastActivityAt: 1, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cachedTokens: 0, + totalTokens: 15, + contextTokens: 15, + compactThresholdTokens: 60, + compactPercent: 25, + }, + }, + ], + }, + ], + }, + { + sessions: {}, + pendingCreates: {}, + availableModels: [], + }, + ) + + renderWithStore( + <PaneContainer tabId="tab-1" node={node} />, + store, + ) + + expect(screen.getByText('Kilroy')).toBeInTheDocument() + expect(screen.getByText(/freshell \(main\)\s+25%/)).toBeInTheDocument() + }) + it('does not add the token-budget indicator to kilroy panes', () => { const node: PaneNode = { type: 'leaf', diff --git a/test/unit/client/components/panes/PanePicker.test.tsx b/test/unit/client/components/panes/PanePicker.test.tsx index d61bf0746..e070ba54d 100644 --- a/test/unit/client/components/panes/PanePicker.test.tsx +++ b/test/unit/client/components/panes/PanePicker.test.tsx @@ -222,7 +222,7 @@ describe('PanePicker', () => { expect(codexButton.querySelector('img')).not.toBeInTheDocument() }) - it('renders options in correct order: Freshclaude, CLIs, Editor, Browser, Shell (Kilroy hidden by default)', () => { + it('renders options in correct order: Freshclaude, CLIs, Freshcodex, Editor, Browser, Shell (Kilroy hidden by default)', () => { renderPicker({ availableClis: { claude: true, codex: true }, enabledProviders: ['claude', 'codex'], @@ -233,9 +233,10 @@ describe('PanePicker', () => { expect(labels[0]).toBe('Freshclaude') expect(labels[1]).toBe('Claude CLI') expect(labels[2]).toBe('Codex CLI') - expect(labels[3]).toBe('Editor') - expect(labels[4]).toBe('Browser') - expect(labels[5]).toBe('Shell') + expect(labels[3]).toBe('Freshcodex') + expect(labels[4]).toBe('Editor') + expect(labels[5]).toBe('Browser') + expect(labels[6]).toBe('Shell') expect(labels).not.toContain('Kilroy') }) @@ -254,9 +255,10 @@ describe('PanePicker', () => { expect(labels[1]).toBe('Claude CLI') expect(labels[2]).toBe('Codex CLI') expect(labels[3]).toBe('Kilroy') - expect(labels[4]).toBe('Editor') - expect(labels[5]).toBe('Browser') - expect(labels[6]).toBe('Shell') + expect(labels[4]).toBe('Freshcodex') + expect(labels[5]).toBe('Editor') + expect(labels[6]).toBe('Browser') + expect(labels[7]).toBe('Shell') }) it('shows only non-CLI options when no CLIs are available', () => { @@ -592,7 +594,7 @@ describe('PanePicker', () => { }) describe('balanced icon layout', () => { - it('prefers a balanced 3+3 arrangement when six options are visible', () => { + it('prefers a balanced 3+2+2 arrangement when seven options are visible', () => { renderPicker({ availableClis: { claude: true, codex: true }, enabledProviders: ['claude', 'codex'], @@ -600,9 +602,10 @@ describe('PanePicker', () => { }) const rows = screen.getAllByTestId('pane-picker-option-row') - expect(rows).toHaveLength(2) + expect(rows).toHaveLength(3) expect(within(rows[0]).getAllByRole('button')).toHaveLength(3) - expect(within(rows[1]).getAllByRole('button')).toHaveLength(3) + expect(within(rows[1]).getAllByRole('button')).toHaveLength(2) + expect(within(rows[2]).getAllByRole('button')).toHaveLength(2) }) }) diff --git a/test/unit/client/components/settings-view-test-utils.tsx b/test/unit/client/components/settings-view-test-utils.tsx index 5355a46aa..63a075452 100644 --- a/test/unit/client/components/settings-view-test-utils.tsx +++ b/test/unit/client/components/settings-view-test-utils.tsx @@ -126,9 +126,12 @@ export function createTabRegistryState(overrides: Partial<TabRegistryState> = {} deviceId: 'local-device', deviceLabel: 'local-device', localOpen: [], + sameDeviceOpen: [], remoteOpen: [], + devices: [], closed: [], localClosed: {}, + closedTabRetentionDays: 30, loading: false, searchRangeDays: 30, ...overrides, diff --git a/test/unit/client/components/terminal-view-utils.test.ts b/test/unit/client/components/terminal-view-utils.test.ts index 8595dc23a..f5be683ca 100644 --- a/test/unit/client/components/terminal-view-utils.test.ts +++ b/test/unit/client/components/terminal-view-utils.test.ts @@ -54,4 +54,44 @@ describe('terminal-view-utils', () => { }, }) }) + + it('uses Codex durability state for create only when no durable sessionRef exists', () => { + const codexDurability = { + schemaVersion: 1 as const, + state: 'captured_pre_turn' as const, + candidate: { + provider: 'codex' as const, + candidateThreadId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + source: 'thread_started_notification' as const, + capturedAt: 1778743920000, + }, + } + const ref: { current: TerminalPaneContent | null } = { + current: { + kind: 'terminal', + createRequestId: 'req-3', + status: 'creating', + mode: 'codex', + shell: 'system', + codexDurability, + }, + } + + expect(getCreateSessionStateFromRef(ref)).toEqual({ codexDurability }) + + ref.current = { + ...ref.current, + sessionRef: { + provider: 'codex', + sessionId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + }, + } + expect(getCreateSessionStateFromRef(ref)).toEqual({ + sessionRef: { + provider: 'codex', + sessionId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + }, + }) + }) }) diff --git a/test/unit/client/components/ui/error-boundary.test.tsx b/test/unit/client/components/ui/error-boundary.test.tsx index ed507e9fe..509d41601 100644 --- a/test/unit/client/components/ui/error-boundary.test.tsx +++ b/test/unit/client/components/ui/error-boundary.test.tsx @@ -125,4 +125,101 @@ describe('ErrorBoundary', () => { await user.click(screen.getByRole('button', { name: 'Go to Overview' })) expect(onNavigate).toHaveBeenCalledTimes(1) }) + + describe('chunk-load error recovery', () => { + const originalReload = window.location.reload + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: vi.fn() }, + writable: true, + configurable: true, + }) + sessionStorage.clear() + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: originalReload }, + writable: true, + configurable: true, + }) + }) + + it('reloads the page when Try Again is clicked on a Chrome chunk-load error', async () => { + const user = userEvent.setup() + const chunkError = new TypeError( + 'Failed to fetch dynamically imported module: http://192.168.3.50:3001/assets/EditorPane-DAYbRo9B.js' + ) + function BadImport() { + throw chunkError + } + render( + <ErrorBoundary label="Editor"> + <BadImport /> + </ErrorBoundary> + ) + await user.click(screen.getByRole('button', { name: 'Try Again' })) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Firefox-style chunk error', async () => { + const user = userEvent.setup() + const err = new TypeError( + 'error loading dynamically imported module: http://localhost/assets/chunk.js' + ) + function BadImport() { + throw err + } + render( + <ErrorBoundary> + <BadImport /> + </ErrorBoundary> + ) + await user.click(screen.getByRole('button', { name: 'Try Again' })) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('still resets state for non-chunk errors (no reload)', async () => { + const user = userEvent.setup() + let shouldThrow = true + function ToggleChild() { + if (shouldThrow) throw new Error('Regular error') + return <div>Recovered</div> + } + const { container, rerender } = render( + <ErrorBoundary> + <ToggleChild /> + </ErrorBoundary> + ) + expect(within(container).getByRole('alert')).toBeInTheDocument() + shouldThrow = false + await user.click(within(container).getByRole('button', { name: 'Try Again' })) + rerender( + <ErrorBoundary> + <ToggleChild /> + </ErrorBoundary> + ) + expect(within(container).getByText('Recovered')).toBeInTheDocument() + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('does not reload if circuit breaker is tripped', async () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + const user = userEvent.setup() + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk.js' + ) + function BadImport() { + throw err + } + render( + <ErrorBoundary> + <BadImport /> + </ErrorBoundary> + ) + await user.click(screen.getByRole('button', { name: 'Try Again' })) + expect(window.location.reload).not.toHaveBeenCalled() + }) + }) }) diff --git a/test/unit/client/lib/api.test.ts b/test/unit/client/lib/api.test.ts index e688c32d7..67c6f8fd3 100644 --- a/test/unit/client/lib/api.test.ts +++ b/test/unit/client/lib/api.test.ts @@ -3,6 +3,9 @@ import { api, getAgentChatCapabilities, refreshAgentChatCapabilities, + getFreshAgentThreadSnapshot, + getFreshAgentTurnBody, + getFreshAgentTurnPage, fetchSidebarSessionsSnapshot, getAgentTimelinePage, getAgentTurnBody, @@ -21,6 +24,11 @@ import { SessionDirectoryQuerySchema, TerminalDirectoryQuerySchema, } from '@shared/read-models' +import { + codexContractSnapshot, + codexContractTurnBody, + codexContractTurnPage, +} from '../../../fixtures/fresh-agent/codex/contract-fixtures.js' const mockFetch = vi.fn() global.fetch = mockFetch @@ -176,6 +184,34 @@ describe('visible-first read-model helpers', () => { }) }) + it('fresh-agent helpers target the fresh-agent route family and pin provider, revision, and cursor', async () => { + const signal = new AbortController().signal + mockFetch + .mockResolvedValueOnce(mockJson(codexContractSnapshot)) + .mockResolvedValueOnce(mockJson(codexContractTurnPage)) + .mockResolvedValueOnce(mockJson(codexContractTurnBody)) + + await getFreshAgentThreadSnapshot('freshcodex', 'codex', 'thread-1', { revision: 7, signal }) + await getFreshAgentTurnPage('freshcodex', 'codex', 'thread-1', { revision: 7, cursor: 'cursor-1', limit: 20 }, { signal }) + await getFreshAgentTurnBody('freshcodex', 'codex', 'thread-1', 'turn-1', { revision: 7, signal }) + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + '/api/fresh-agent/threads/freshcodex/codex/thread-1?revision=7', + expect.objectContaining({ signal, headers: expect.any(Headers) }), + ) + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + '/api/fresh-agent/threads/freshcodex/codex/thread-1/turns?revision=7&cursor=cursor-1&limit=20', + expect.objectContaining({ signal, headers: expect.any(Headers) }), + ) + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + '/api/fresh-agent/threads/freshcodex/codex/thread-1/turns/turn-1?revision=7', + expect.objectContaining({ signal, headers: expect.any(Headers) }), + ) + }) + it('rejects timeline requests that omit the pinned restore revision', async () => { await expect(getAgentTimelinePage('session-1', { priority: 'visible' }, { signal: new AbortController().signal })) .rejects @@ -341,6 +377,60 @@ describe('visible-first read-model helpers', () => { ]) }) + it('preserves session-directory running state in sidebar snapshots', async () => { + mockFetch.mockResolvedValueOnce(mockJson({ + items: [{ + sessionId: 'codex-live-1', + provider: 'codex', + projectPath: '/repo/live', + title: 'Live Codex', + sessionType: 'codex', + isRunning: true, + runningTerminalId: 'term-codex-1', + lastActivityAt: 1_700, + }], + nextCursor: null, + revision: 1_700, + })) + + const response = await fetchSidebarSessionsSnapshot() + + expect(response.projects[0].sessions[0]).toMatchObject({ + provider: 'codex', + sessionId: 'codex-live-1', + isRunning: true, + runningTerminalId: 'term-codex-1', + }) + }) + + it('preserves live-terminal-only state in sidebar snapshots', async () => { + mockFetch.mockResolvedValueOnce(mockJson({ + items: [{ + sessionId: 'terminal:term-opencode-live', + provider: 'opencode', + projectPath: '/repo/live', + title: 'OpenCode', + sessionType: 'opencode', + isRunning: true, + runningTerminalId: 'term-opencode-live', + liveTerminalOnly: true, + lastActivityAt: 1_700, + }], + nextCursor: null, + revision: 1_700, + })) + + const response = await fetchSidebarSessionsSnapshot() + + expect(response.projects[0].sessions[0]).toMatchObject({ + provider: 'opencode', + sessionId: 'terminal:term-opencode-live', + isRunning: true, + runningTerminalId: 'term-opencode-live', + liveTerminalOnly: true, + }) + }) + it('encodes session-directory cursors with lastActivityAt', async () => { mockFetch.mockResolvedValueOnce(mockJson({ items: [], @@ -394,6 +484,60 @@ describe('visible-first read-model helpers', () => { }), ]) }) + + it('preserves session-directory running state in search results', async () => { + mockFetch.mockResolvedValueOnce(mockJson({ + items: [{ + sessionId: 'ses_live_opencode', + provider: 'opencode', + projectPath: '/repo/live', + title: 'Live OpenCode', + matchedIn: 'title', + isRunning: true, + runningTerminalId: 'term-opencode-1', + lastActivityAt: 1_800, + }], + nextCursor: null, + revision: 1_800, + })) + + const response = await searchSessions({ query: 'live', tier: 'title' }) + + expect(response.results[0]).toMatchObject({ + provider: 'opencode', + sessionId: 'ses_live_opencode', + isRunning: true, + runningTerminalId: 'term-opencode-1', + }) + }) + + it('preserves live-terminal-only state in search results', async () => { + mockFetch.mockResolvedValueOnce(mockJson({ + items: [{ + sessionId: 'terminal:term-opencode-live', + provider: 'opencode', + projectPath: '/repo/live', + title: 'OpenCode', + matchedIn: 'title', + isRunning: true, + runningTerminalId: 'term-opencode-live', + liveTerminalOnly: true, + lastActivityAt: 1_800, + }], + nextCursor: null, + revision: 1_800, + })) + + const response = await searchSessions({ query: 'OpenCode', tier: 'title' }) + + expect(response.results[0]).toMatchObject({ + provider: 'opencode', + sessionId: 'terminal:term-opencode-live', + isRunning: true, + runningTerminalId: 'term-opencode-live', + liveTerminalOnly: true, + }) + }) }) describe('searchSessions tier forwarding', () => { diff --git a/test/unit/client/lib/browser-preferences.test.ts b/test/unit/client/lib/browser-preferences.test.ts index 60bb9b7fa..4850c153e 100644 --- a/test/unit/client/lib/browser-preferences.test.ts +++ b/test/unit/client/lib/browser-preferences.test.ts @@ -22,7 +22,7 @@ describe('browser preferences', () => { }, }, tabs: { - searchRangeDays: 90, + closedTabRetentionDays: 30, }, })) @@ -34,7 +34,7 @@ describe('browser preferences', () => { }, }, tabs: { - searchRangeDays: 90, + closedTabRetentionDays: 30, }, }) }) @@ -123,13 +123,13 @@ describe('browser preferences', () => { }) }) - it('reads search-range preferences from the new blob', () => { + it('clamps legacy search-range preferences to the new retention limit', () => { patchBrowserPreferencesRecord({ tabs: { searchRangeDays: 365, }, }) - expect(getSearchRangeDaysPreference()).toBe(365) + expect(getSearchRangeDaysPreference()).toBe(30) }) }) diff --git a/test/unit/client/lib/chunk-error-recovery.test.ts b/test/unit/client/lib/chunk-error-recovery.test.ts new file mode 100644 index 000000000..42b8c040e --- /dev/null +++ b/test/unit/client/lib/chunk-error-recovery.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { initChunkErrorRecovery } from '@/lib/import-retry' + +function createRejectionEvent(reason: unknown): Event { + const event = new Event('unhandledrejection', { cancelable: true }) + Object.defineProperty(event, 'reason', { value: reason, writable: false }) + return event +} + +describe('initChunkErrorRecovery', () => { + const originalReload = window.location.reload + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: vi.fn() }, + writable: true, + configurable: true, + }) + sessionStorage.clear() + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: originalReload }, + writable: true, + configurable: true, + }) + }) + + it('reloads on vite:preloadError event', () => { + initChunkErrorRecovery() + const event = new Event('vite:preloadError', { cancelable: true }) + window.dispatchEvent(event) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on unhandledrejection with chunk-load error', () => { + initChunkErrorRecovery() + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + window.dispatchEvent(createRejectionEvent(err)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('does not reload on unhandledrejection with non-chunk error', () => { + initChunkErrorRecovery() + const err = new Error('Something else') + window.dispatchEvent(createRejectionEvent(err)) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('respects circuit breaker on vite:preloadError', () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + initChunkErrorRecovery() + const event = new Event('vite:preloadError', { cancelable: true }) + window.dispatchEvent(event) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('is idempotent — calling initChunkErrorRecovery multiple times does not double-fire', () => { + initChunkErrorRecovery() + initChunkErrorRecovery() + const event = new Event('vite:preloadError', { cancelable: true }) + window.dispatchEvent(event) + expect(window.location.reload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts new file mode 100644 index 000000000..843d58499 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' +import freshAgentReducer from '@/store/freshAgentSlice' +import { handleFreshAgentMessage, registerFreshAgentCreate } from '@/lib/fresh-agent-ws' +import { cancelCreate, _resetCancelledCreates } from '@/lib/sdk-message-handler' + +describe('fresh-agent-ws', () => { + beforeEach(() => { + _resetCancelledCreates() + }) + + it('registers resumed creates with history hydration and handles freshAgent.created', () => { + const store = configureStore({ + reducer: { + freshAgent: freshAgentReducer, + }, + }) + + registerFreshAgentCreate(store.dispatch, 'req-1', { + resumeSessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + }) + const handled = handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.created', + requestId: 'req-1', + sessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + }) + + expect(handled).toBe(true) + expect(store.getState().freshAgent.pendingCreates['req-1']).toMatchObject({ + sessionId: 'thread-1', + expectsHistoryHydration: true, + }) + }) + + it('kills a late freshAgent.created session when its create request was cancelled', () => { + const store = configureStore({ + reducer: { + freshAgent: freshAgentReducer, + }, + }) + const ws = { send: vi.fn() } + + registerFreshAgentCreate(store.dispatch, 'req-orphan', { + sessionType: 'freshcodex', + provider: 'codex', + }) + cancelCreate('req-orphan') + + const handled = handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.created', + requestId: 'req-orphan', + sessionId: 'thread-orphan', + sessionType: 'freshcodex', + provider: 'codex', + }, ws) + + expect(handled).toBe(true) + expect(ws.send).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'thread-orphan', + sessionType: 'freshcodex', + provider: 'codex', + }) + expect(store.getState().freshAgent.sessions['freshcodex:codex:thread-orphan']).toBeUndefined() + }) + + it('handles freshAgent.create.failed', () => { + const store = configureStore({ + reducer: { + freshAgent: freshAgentReducer, + }, + }) + + const handled = handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.create.failed', + requestId: 'req-2', + code: 'NOPE', + message: 'No provider', + retryable: false, + }) + + expect(handled).toBe(true) + expect(store.getState().freshAgent.pendingCreateFailures['req-2']).toEqual({ + code: 'NOPE', + message: 'No provider', + retryable: false, + }) + }) + + it('projects Claude freshAgent.event snapshot and lost-session transport updates into fresh-agent session state', () => { + const store = configureStore({ + reducer: { + freshAgent: freshAgentReducer, + }, + }) + + expect(handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.event', + sessionId: 'claude-thread-1', + sessionType: 'freshclaude', + provider: 'claude', + event: { + type: 'sdk.session.snapshot', + sessionId: 'claude-thread-1', + latestTurnId: 'turn-1', + status: 'idle', + timelineSessionId: 'cli-session-1', + revision: 7, + }, + })).toBe(true) + + expect(handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.event', + sessionId: 'claude-thread-1', + sessionType: 'freshclaude', + provider: 'claude', + event: { + type: 'sdk.error', + sessionId: 'claude-thread-1', + code: 'INVALID_SESSION_ID', + message: 'Session missing on server', + }, + })).toBe(true) + + expect(store.getState().freshAgent.sessions['freshclaude:claude:claude-thread-1']).toEqual(expect.objectContaining({ + latestTurnId: 'turn-1', + timelineSessionId: 'cli-session-1', + timelineRevision: 7, + lost: true, + historyLoaded: false, + })) + }) +}) diff --git a/test/unit/client/lib/import-retry.test.ts b/test/unit/client/lib/import-retry.test.ts new file mode 100644 index 000000000..d61b48e74 --- /dev/null +++ b/test/unit/client/lib/import-retry.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { withChunkErrorRecovery, shouldReload, isChunkLoadError } from '@/lib/import-retry' + +describe('withChunkErrorRecovery', () => { + const originalReload = window.location.reload + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: vi.fn() }, + writable: true, + configurable: true, + }) + sessionStorage.clear() + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: originalReload }, + writable: true, + configurable: true, + }) + }) + + it('resolves with the module when import succeeds', async () => { + const mod = { foo: 'bar' } + const result = await withChunkErrorRecovery(Promise.resolve(mod)) + expect(result).toBe(mod) + }) + + describe('chunk-load error detection', () => { + it('reloads on Chrome-style chunk error', async () => { + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Firefox-style chunk error', async () => { + const err = new TypeError( + 'error loading dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Safari-style chunk error', async () => { + const err = new TypeError('Importing a module script failed.') + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Vite-style chunk failure', async () => { + const err = new TypeError('loading chunk 42 failed') + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('does not reload on unrelated TypeError', async () => { + const err = new TypeError('NetworkError when attempting to fetch resource.') + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + }) + + describe('circuit breaker', () => { + it('does not reload if a reload happened within cooldown window', async () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('reloads if the previous reload was outside cooldown window', async () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now() - 20_000)) + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + }) + + it('re-throws regular Error unchanged', async () => { + const err = new Error('Something else broke') + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('re-throws non-Error rejections unchanged', async () => { + await expect(withChunkErrorRecovery(Promise.reject('string failure'))).rejects.toBe( + 'string failure' + ) + expect(window.location.reload).not.toHaveBeenCalled() + }) +}) + +describe('shouldReload', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('returns true on first call', () => { + expect(shouldReload()).toBe(true) + }) + + it('returns false if called within cooldown window', () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + expect(shouldReload()).toBe(false) + }) + + it('returns true after cooldown expires', () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now() - 20_000)) + expect(shouldReload()).toBe(true) + }) + + it('returns true when sessionStorage is unavailable', () => { + const originalGetItem = sessionStorage.getItem + sessionStorage.getItem = () => { throw new DOMException('SecurityError') } + expect(shouldReload()).toBe(true) + sessionStorage.getItem = originalGetItem + }) +}) + +describe('isChunkLoadError', () => { + it('returns false for non-TypeError errors', () => { + const err = new RangeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk.js' + ) + expect(isChunkLoadError(err)).toBe(false) + }) + + it('returns false for matching message in regular Error', () => { + const err = new Error('importing a module script') + expect(isChunkLoadError(err)).toBe(false) + }) +}) diff --git a/test/unit/client/lib/known-devices.test.ts b/test/unit/client/lib/known-devices.test.ts index 412db5a2f..b1689d6ea 100644 --- a/test/unit/client/lib/known-devices.test.ts +++ b/test/unit/client/lib/known-devices.test.ts @@ -22,13 +22,17 @@ function makeRecord(overrides: Partial<RegistryTabRecord>): RegistryTabRecord { } describe('buildKnownDevices', () => { - it('deduplicates remote devices that share the same stored machine label', () => { + it('uses server device metadata as the source of truth and preserves distinct ids with the same label', () => { const devices = buildKnownDevices({ ownDeviceId: 'local-device', ownDeviceLabel: 'local-device', remoteOpen: [ makeRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), ], + devices: [ + { deviceId: 'remote-a', deviceLabel: 'studio-mac', lastSeenAt: 10 }, + { deviceId: 'remote-b', deviceLabel: 'studio-mac', lastSeenAt: 5 }, + ], closed: [ makeRecord({ deviceId: 'remote-b', @@ -43,9 +47,23 @@ describe('buildKnownDevices', () => { }) const remoteDevices = devices.filter((device) => !device.isOwn) - expect(remoteDevices).toHaveLength(1) - expect(remoteDevices[0]?.baseLabel).toBe('studio-mac') - expect([...(remoteDevices[0]?.deviceIds || [])].sort()).toEqual(['remote-a', 'remote-b']) + expect(remoteDevices).toHaveLength(2) + expect(remoteDevices.map((device) => device.deviceIds)).toEqual([['remote-a'], ['remote-b']]) + expect(remoteDevices.map((device) => device.baseLabel)).toEqual(['studio-mac', 'studio-mac']) + }) + + it('does not infer remote device rows from open tab records when server metadata is absent', () => { + const devices = buildKnownDevices({ + ownDeviceId: 'local-device', + ownDeviceLabel: 'local-device', + remoteOpen: [ + makeRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), + ], + devices: [], + }) + + expect(devices).toHaveLength(1) + expect(devices[0]?.isOwn).toBe(true) }) it('hides dismissed device ids from the rendered list', () => { @@ -56,6 +74,10 @@ describe('buildKnownDevices', () => { remoteOpen: [ makeRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), ], + devices: [ + { deviceId: 'remote-a', deviceLabel: 'studio-mac', lastSeenAt: 10 }, + { deviceId: 'remote-b', deviceLabel: 'studio-mac', lastSeenAt: 5 }, + ], closed: [ makeRecord({ deviceId: 'remote-b', diff --git a/test/unit/client/lib/session-contract.test.ts b/test/unit/client/lib/session-contract.test.ts index 4110c7cdb..7123a04b8 100644 --- a/test/unit/client/lib/session-contract.test.ts +++ b/test/unit/client/lib/session-contract.test.ts @@ -21,6 +21,7 @@ describe('client session-contract helpers', () => { { provider: 'codex', sessionId: 'codex-session-1', + serverInstanceId: 'srv-local', }, { provider: 'codex', diff --git a/test/unit/client/lib/session-type-utils.test.ts b/test/unit/client/lib/session-type-utils.test.ts index 4907f965d..68ae929e4 100644 --- a/test/unit/client/lib/session-type-utils.test.ts +++ b/test/unit/client/lib/session-type-utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { resolveSessionTypeConfig, buildResumeContent } from '@/lib/session-type-utils' +import { CodexIcon } from '@/components/icons/provider-icons' describe('resolveSessionTypeConfig', () => { it('returns claude config for "claude"', () => { @@ -20,6 +21,12 @@ describe('resolveSessionTypeConfig', () => { expect(config.icon).toBeDefined() }) + it('returns the registry-backed Codex icon for "freshcodex"', () => { + const config = resolveSessionTypeConfig('freshcodex') + expect(config.label).toBe('Freshcodex') + expect(config.icon).toBe(CodexIcon) + }) + it('returns kilroy config for "kilroy"', () => { const config = resolveSessionTypeConfig('kilroy') expect(config.label).toBe('Kilroy') @@ -34,39 +41,41 @@ describe('resolveSessionTypeConfig', () => { }) describe('buildResumeContent', () => { - it('returns agent-chat content for freshclaude sessionType', () => { + it('returns fresh-agent content for freshclaude sessionType', () => { const content = buildResumeContent({ sessionType: 'freshclaude', sessionId: 'abc-123', cwd: '/home/user/project', }) - expect(content.kind).toBe('agent-chat') - if (content.kind !== 'agent-chat') throw new Error('expected agent-chat') - expect(content.provider).toBe('freshclaude') + expect(content.kind).toBe('fresh-agent') + if (content.kind !== 'fresh-agent') throw new Error('expected fresh-agent') + expect(content.sessionType).toBe('freshclaude') + expect(content.provider).toBe('claude') + expect(content.resumeSessionId).toBe('abc-123') expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: 'abc-123', }) - expect(content.resumeSessionId).toBeUndefined() expect(content.initialCwd).toBe('/home/user/project') expect(content.modelSelection).toBeUndefined() expect(content.permissionMode).toBe('bypassPermissions') // default from provider config expect(content.effort).toBeUndefined() }) - it('returns agent-chat content for kilroy sessionType', () => { + it('returns fresh-agent content for kilroy sessionType', () => { const content = buildResumeContent({ sessionType: 'kilroy', sessionId: 'xyz-789', }) - expect(content.kind).toBe('agent-chat') - if (content.kind !== 'agent-chat') throw new Error('expected agent-chat') - expect(content.provider).toBe('kilroy') + expect(content.kind).toBe('fresh-agent') + if (content.kind !== 'fresh-agent') throw new Error('expected fresh-agent') + expect(content.sessionType).toBe('kilroy') + expect(content.provider).toBe('claude') + expect(content.resumeSessionId).toBe('xyz-789') expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: 'xyz-789', }) - expect(content.resumeSessionId).toBeUndefined() }) it('returns terminal content for claude sessionType', () => { @@ -127,12 +136,32 @@ describe('buildResumeContent', () => { expect(content.resumeSessionId).toBeUndefined() }) - it('agent-chat panes have no terminalId', () => { + it('returns terminal content with live terminal fields when supplied', () => { + const content = buildResumeContent({ + sessionType: 'codex', + sessionId: 'def-456', + liveTerminal: { + terminalId: 'term-codex-1', + serverInstanceId: 'srv-local', + }, + }) + + expect(content.kind).toBe('terminal') + if (content.kind !== 'terminal') throw new Error('expected terminal') + expect(content).toMatchObject({ + terminalId: 'term-codex-1', + serverInstanceId: 'srv-local', + status: 'running', + }) + expect('liveTerminal' in content).toBe(false) + }) + + it('fresh-agent panes have no terminalId', () => { const content = buildResumeContent({ sessionType: 'freshclaude', sessionId: 'abc-123', }) - expect(content.kind).toBe('agent-chat') + expect(content.kind).toBe('fresh-agent') expect('terminalId' in content).toBe(false) }) @@ -146,8 +175,8 @@ describe('buildResumeContent', () => { effort: 'turbo', }, }) - expect(content.kind).toBe('agent-chat') - if (content.kind !== 'agent-chat') throw new Error('expected agent-chat') + expect(content.kind).toBe('fresh-agent') + if (content.kind !== 'fresh-agent') throw new Error('expected fresh-agent') expect(content.modelSelection).toEqual({ kind: 'tracked', modelId: 'opus[1m]' }) expect(content.permissionMode).toBe('default') expect(content.effort).toBe('turbo') @@ -158,8 +187,8 @@ describe('buildResumeContent', () => { sessionType: 'freshclaude', sessionId: 'abc-123', }) - expect(content.kind).toBe('agent-chat') - if (content.kind !== 'agent-chat') throw new Error('expected agent-chat') + expect(content.kind).toBe('fresh-agent') + if (content.kind !== 'fresh-agent') throw new Error('expected fresh-agent') expect(content.effort).toBeUndefined() }) diff --git a/test/unit/client/lib/session-utils.test.ts b/test/unit/client/lib/session-utils.test.ts index e3aff2b3b..9ad671531 100644 --- a/test/unit/client/lib/session-utils.test.ts +++ b/test/unit/client/lib/session-utils.test.ts @@ -18,7 +18,7 @@ import type { import type { RootState } from '@/store/store' const VALID_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' -const OTHER_SESSION_ID = '6f1c2b3a-4d5e-6f70-8a9b-0c1d2e3f4a5b' +const OTHER_SESSION_ID = '6f1c2b3a-4d5e-4f70-8a9b-0c1d2e3f4a5b' function terminalContent( mode: TerminalPaneContent['mode'], diff --git a/test/unit/client/lib/tab-fallback-identity.test.ts b/test/unit/client/lib/tab-fallback-identity.test.ts new file mode 100644 index 000000000..a8b98b881 --- /dev/null +++ b/test/unit/client/lib/tab-fallback-identity.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { buildTabFallbackIdentityUpdates, sanitizeTabsAgainstLayouts } from '@/lib/tab-fallback-identity' +import type { PaneNode } from '@/store/paneTypes' +import type { Tab } from '@/store/types' + +const VALID_CLAUDE_SESSION_ID = '00000000-0000-4000-8000-000000000444' +const CODEX_THREAD_ID = 'codex-thread-123' + +function makeLeaf(content: PaneNode['content']): Extract<PaneNode, { type: 'leaf' }> { + return { type: 'leaf', id: 'pane-1', content } +} + +describe('buildTabFallbackIdentityUpdates', () => { + it('derives sessionRef from fresh-agent.sessionRef for a single-pane tab', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: undefined }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + }) + expect(result).toBeDefined() + expect(result!.sessionRef).toEqual({ provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }) + }) + + it('derives sessionRef from fresh-agent with canonical Claude resumeSessionId', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: undefined }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + resumeSessionId: VALID_CLAUDE_SESSION_ID, + }), + }) + expect(result).toBeDefined() + expect(result!.resumeSessionId).toBeUndefined() + expect(result!.sessionRef).toMatchObject({ provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }) + }) + + it('returns undefined for fresh-agent with non-canonical named resume alias', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: undefined }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + resumeSessionId: 'named-resume', + }), + }) + expect(result?.sessionRef).toBeUndefined() + }) + + it('derives sessionRef from Codex fresh-agent with Codex sessionRef', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: undefined }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'codex', sessionId: CODEX_THREAD_ID }, + }), + }) + expect(result).toBeDefined() + expect(result!.sessionRef).toEqual({ provider: 'codex', sessionId: CODEX_THREAD_ID }) + }) + + it('clears stale resumeSessionId from the tab', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: 'stale-resume-alias' }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + }) + expect(result).toBeDefined() + expect(result!.resumeSessionId).toBeUndefined() + expect(result!.sessionRef).toEqual({ provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }) + }) + + it('returns undefined when tab already has the correct sessionRef', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, resumeSessionId: undefined }, + layout: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + }) + expect(result).toBeUndefined() + }) + + it('returns undefined for a split layout without recursive leaf analysis', () => { + const result = buildTabFallbackIdentityUpdates({ + tab: { id: 't1', mode: 'shell', sessionRef: undefined, resumeSessionId: undefined }, + layout: { + type: 'split', + id: 'split-1', + direction: 'horizontal', + sizes: [0.5, 0.5], + children: [ + makeLeaf({ + kind: 'terminal', + mode: 'shell', + status: 'running', + createRequestId: 'req-terminal', + }), + makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + ], + }, + }) + expect(result).toBeUndefined() + }) +}) + +describe('sanitizeTabsAgainstLayouts', () => { + const makeTab = ( + id: string, + overrides: Partial<Pick<Tab, 'sessionRef' | 'resumeSessionId' | 'mode'>> = {}, + ): Pick<Tab, 'id' | 'mode' | 'sessionRef' | 'resumeSessionId'> => ({ + id, + mode: 'shell' as Tab['mode'], + sessionRef: undefined, + resumeSessionId: undefined, + ...overrides, + }) + + it('updates tab identity from a fresh-agent pane with sessionRef', () => { + const tabs = [makeTab('t1')] + const layouts = { + t1: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + } + const result = sanitizeTabsAgainstLayouts(tabs, layouts) + expect(result).not.toBe(tabs) + expect(result[0].sessionRef).toEqual({ provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }) + }) + + it('returns the same array reference when no changes are needed', () => { + const tabs = [ + makeTab('t1', { sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID } }), + ] + const layouts = { + t1: makeLeaf({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'connected', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }), + } + const result = sanitizeTabsAgainstLayouts(tabs, layouts) + expect(result).toBe(tabs) + }) +}) diff --git a/test/unit/client/lib/tab-recency.test.ts b/test/unit/client/lib/tab-recency.test.ts new file mode 100644 index 000000000..43fc311d0 --- /dev/null +++ b/test/unit/client/lib/tab-recency.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + TAB_RECENCY_RESOLUTION_MS, + bucketTabRecencyAt, + collectTerminalPaneIds, + deriveTabRecencyAt, +} from '@/lib/tab-recency' + +describe('tab recency helpers', () => { + it('rounds timestamps down to 60-second buckets', () => { + expect(TAB_RECENCY_RESOLUTION_MS).toBe(60_000) + expect(bucketTabRecencyAt(1_740_000_059_999)).toBe(1_740_000_000_000) + expect(bucketTabRecencyAt(1_740_000_060_000)).toBe(1_740_000_060_000) + }) + + it('ignores missing and invalid timestamps', () => { + expect(bucketTabRecencyAt(undefined)).toBeUndefined() + expect(bucketTabRecencyAt(null)).toBeUndefined() + expect(bucketTabRecencyAt(Number.NaN)).toBeUndefined() + expect(bucketTabRecencyAt(-1)).toBeUndefined() + }) + + it('collects only current terminal pane ids', () => { + const layout = { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-terminal', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-picker', + content: { kind: 'picker' }, + }, + ], + } as any + + expect(collectTerminalPaneIds(layout)).toEqual(['pane-terminal']) + }) + + it('derives tab recency from latest terminal-pane activity and tab fallback fields', () => { + const tab = { + id: 'tab-1', + createdAt: 1_740_000_000_000, + lastInputAt: 1_740_000_020_000, + } + const layout = { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-2', + content: { kind: 'terminal' }, + }, + ], + } as any + + expect(deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: { + 'pane-1': 1_740_000_030_000, + 'pane-2': 1_740_000_080_000, + }, + })).toBe(1_740_000_060_000) + }) + + it('does not treat tab.updatedAt or non-terminal pane ids as activity recency', () => { + const tab = { + id: 'tab-1', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_180_000, + lastInputAt: 1_740_000_080_000, + } as any + const layout = { + type: 'leaf', + id: 'pane-1', + content: { kind: 'picker' }, + } as any + + expect(deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: { + 'pane-1': 1_740_000_240_000, + }, + })).toBe(1_740_000_060_000) + }) +}) diff --git a/test/unit/client/lib/tab-registry-snapshot.test.ts b/test/unit/client/lib/tab-registry-snapshot.test.ts index 276422099..ad689f487 100644 --- a/test/unit/client/lib/tab-registry-snapshot.test.ts +++ b/test/unit/client/lib/tab-registry-snapshot.test.ts @@ -37,6 +37,54 @@ describe('shouldKeepClosedTab', () => { }) describe('collectPaneSnapshots', () => { + it('serializes candidate-only Codex durability state for registry reopen surfaces', () => { + const codexDurability = { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: '019e2413-b8d0-7a98-b5fb-2f4af05baf58', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + source: 'thread_start_response', + capturedAt: 1778764200000, + }, + } as const + const node: PaneNode = { + type: 'leaf', + id: 'pane-codex', + content: { + kind: 'terminal', + createRequestId: 'req-codex', + status: 'running', + mode: 'codex', + shell: 'system', + terminalId: 'term-codex', + serverInstanceId: 'server-1', + codexDurability, + initialCwd: '/home/user/code/freshell', + }, + } + + const snapshots = collectPaneSnapshots(node, 'server-1') + + expect(snapshots).toEqual([{ + paneId: 'pane-codex', + kind: 'terminal', + title: undefined, + payload: { + mode: 'codex', + shell: 'system', + sessionRef: undefined, + codexDurability, + liveTerminal: { + terminalId: 'term-codex', + serverInstanceId: 'server-1', + }, + initialCwd: '/home/user/code/freshell', + }, + }]) + }) + it('serializes agent-chat selection strategies and explicit effort overrides', () => { const node: PaneNode = { type: 'leaf', @@ -62,12 +110,7 @@ describe('collectPaneSnapshots', () => { title: undefined, payload: { provider: 'freshclaude', - resumeSessionId: '00000000-0000-4000-8000-000000000123', - sessionRef: { - provider: 'claude', - sessionId: '00000000-0000-4000-8000-000000000123', - serverInstanceId: 'server-1', - }, + sessionRef: undefined, initialCwd: undefined, modelSelection: { kind: 'tracked', modelId: 'opus[1m]' }, permissionMode: 'default', diff --git a/test/unit/client/lib/terminal-behavior.test.ts b/test/unit/client/lib/terminal-behavior.test.ts index 74305251d..6067a7dcf 100644 --- a/test/unit/client/lib/terminal-behavior.test.ts +++ b/test/unit/client/lib/terminal-behavior.test.ts @@ -17,7 +17,7 @@ const extensions: ClientExtensionEntry[] = [{ cli: { terminalBehavior: { preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }, }, }] @@ -26,7 +26,7 @@ describe('terminal behavior', () => { it('returns provider terminal behavior from the extension registry', () => { expect(getProviderTerminalBehavior('opencode', extensions)).toEqual({ preferredRenderer: 'canvas', - scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + scrollInputPolicy: 'native', }) }) diff --git a/test/unit/client/lib/terminal-invalidation-handler.test.ts b/test/unit/client/lib/terminal-invalidation-handler.test.ts new file mode 100644 index 000000000..628976a6c --- /dev/null +++ b/test/unit/client/lib/terminal-invalidation-handler.test.ts @@ -0,0 +1,181 @@ +import { configureStore } from '@reduxjs/toolkit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalInvalidationHandler } from '@/lib/terminal-invalidation-handler' +import { upsertTerminalMeta, removeTerminalMeta } from '@/store/terminalMetaSlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import sessionsReducer, { + commitSessionWindowReplacement, + patchSessionRunningStateFromTerminalMeta, +} from '@/store/sessionsSlice' + +function createRefreshDoubles() { + const terminalDirectoryRefreshThunk = vi.fn() + const activeSessionWindowRefreshThunk = vi.fn() + + return { + terminalDirectoryRefreshThunk, + activeSessionWindowRefreshThunk, + fetchTerminalDirectoryWindow: vi.fn(() => terminalDirectoryRefreshThunk), + queueActiveSessionWindowRefresh: vi.fn(() => activeSessionWindowRefreshThunk), + } +} + +describe('createTerminalInvalidationHandler', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('upserts terminal metadata immediately and coalesces refreshes', async () => { + vi.useFakeTimers() + const dispatch = vi.fn() + const refresh = createRefreshDoubles() + const handler = createTerminalInvalidationHandler({ + dispatch, + upsertTerminalMeta, + removeTerminalMeta, + patchSessionRunningStateFromTerminalMeta, + queueActiveSessionWindowRefresh: refresh.queueActiveSessionWindowRefresh, + fetchTerminalDirectoryWindow: refresh.fetchTerminalDirectoryWindow, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + refreshDelayMs: 50, + }) + + const handled = handler.handle({ + type: 'terminal.meta.updated', + upsert: [{ terminalId: 'term-1', provider: 'codex', sessionId: 'codex-live-1', updatedAt: 1_700 }], + remove: [], + }) + handler.handle({ type: 'terminals.changed', revision: 12 }) + + expect(handled).toBe(true) + expect(dispatch).toHaveBeenCalledWith({ + type: upsertTerminalMeta.type, + payload: [{ terminalId: 'term-1', provider: 'codex', sessionId: 'codex-live-1', updatedAt: 1_700 }], + }) + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: patchSessionRunningStateFromTerminalMeta.type, + })) + expect(refresh.fetchTerminalDirectoryWindow).not.toHaveBeenCalled() + expect(refresh.queueActiveSessionWindowRefresh).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50) + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1) + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledWith({ + surface: 'sidebar', + priority: 'visible', + }) + expect(refresh.queueActiveSessionWindowRefresh).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith(refresh.terminalDirectoryRefreshThunk) + expect(dispatch).toHaveBeenCalledWith(refresh.activeSessionWindowRefreshThunk) + }) + + it('refreshes the sidebar terminal directory and session window after terminals.changed', async () => { + vi.useFakeTimers() + const dispatch = vi.fn() + const refresh = createRefreshDoubles() + const handler = createTerminalInvalidationHandler({ + dispatch, + upsertTerminalMeta, + removeTerminalMeta, + patchSessionRunningStateFromTerminalMeta, + queueActiveSessionWindowRefresh: refresh.queueActiveSessionWindowRefresh, + fetchTerminalDirectoryWindow: refresh.fetchTerminalDirectoryWindow, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + refreshDelayMs: 50, + }) + + const handled = handler.handle({ + type: 'terminals.changed', + revision: 12, + }) + + expect(handled).toBe(true) + await vi.advanceTimersByTimeAsync(50) + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1) + expect(refresh.queueActiveSessionWindowRefresh).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith(refresh.terminalDirectoryRefreshThunk) + expect(dispatch).toHaveBeenCalledWith(refresh.activeSessionWindowRefreshThunk) + }) + + it('flushes a pending refresh before a user-initiated sidebar attach', async () => { + vi.useFakeTimers() + const dispatch = vi.fn() + const refresh = createRefreshDoubles() + const handler = createTerminalInvalidationHandler({ + dispatch, + upsertTerminalMeta, + removeTerminalMeta, + patchSessionRunningStateFromTerminalMeta, + queueActiveSessionWindowRefresh: refresh.queueActiveSessionWindowRefresh, + fetchTerminalDirectoryWindow: refresh.fetchTerminalDirectoryWindow, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + refreshDelayMs: 50, + }) + + handler.handle({ type: 'terminals.changed', revision: 12 }) + handler.flush() + + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1) + expect(refresh.queueActiveSessionWindowRefresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(50) + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1) + }) + + it('patches loaded sessions running before the coalesced fetch fires', async () => { + vi.useFakeTimers() + const refresh = createRefreshDoubles() + const store = configureStore({ + reducer: { + sessions: sessionsReducer, + terminalMeta: terminalMetaReducer, + }, + middleware: (getDefault) => + getDefault({ + serializableCheck: { ignoredPaths: ['sessions.expandedProjects'] }, + }), + }) + store.dispatch(commitSessionWindowReplacement({ + surface: 'sidebar', + projects: [{ + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'codex-live-1', + projectPath: '/repo', + lastActivityAt: 1, + title: 'Live Codex', + }], + }], + })) + + const handler = createTerminalInvalidationHandler({ + dispatch: store.dispatch, + upsertTerminalMeta, + removeTerminalMeta, + patchSessionRunningStateFromTerminalMeta, + queueActiveSessionWindowRefresh: refresh.queueActiveSessionWindowRefresh, + fetchTerminalDirectoryWindow: refresh.fetchTerminalDirectoryWindow, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + refreshDelayMs: 50, + }) + + handler.handle({ + type: 'terminal.meta.updated', + upsert: [{ terminalId: 'term-1', provider: 'codex', sessionId: 'codex-live-1', updatedAt: 1_700 }], + remove: [], + }) + + expect(store.getState().sessions.projects[0]?.sessions[0]).toMatchObject({ + isRunning: true, + runningTerminalId: 'term-1', + }) + expect(refresh.fetchTerminalDirectoryWindow).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50) + expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/client/lib/ws-client.test.ts b/test/unit/client/lib/ws-client.test.ts index 8aa867dd9..f056083f1 100644 --- a/test/unit/client/lib/ws-client.test.ts +++ b/test/unit/client/lib/ws-client.test.ts @@ -314,6 +314,69 @@ describe('WsClient.connect', () => { expect(secondCreates).toEqual(['sdk-reconnect-create-1']) }) + it('resends an in-flight freshAgent.create once after reconnect until freshAgent.created arrives', async () => { + const c = new WsClient('ws://example/ws') + c.send({ + type: 'freshAgent.create', + requestId: 'fresh-agent-reconnect-create-1', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/tmp/project', + } as any) + + const p1 = c.connect() + MockWebSocket.instances[0]._open() + MockWebSocket.instances[0]._message({ type: 'ready' }) + await p1 + MockWebSocket.instances[0]._close(1006, 'drop-after-fresh-agent-create') + + const p2 = c.connect() + MockWebSocket.instances[1]._open() + MockWebSocket.instances[1]._message({ type: 'ready' }) + await p2 + + const secondCreates = MockWebSocket.instances[1].sent + .map((x) => JSON.parse(x)) + .filter((m) => m.type === 'freshAgent.create') + .map((m) => m.requestId) + expect(secondCreates).toEqual(['fresh-agent-reconnect-create-1']) + }) + + it('clears freshAgent.create reconnect tracking when freshAgent.created arrives', async () => { + const c = new WsClient('ws://example/ws') + c.send({ + type: 'freshAgent.create', + requestId: 'fresh-agent-created-before-reconnect', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/tmp/project', + } as any) + + const p1 = c.connect() + MockWebSocket.instances[0]._open() + MockWebSocket.instances[0]._message({ type: 'ready' }) + await p1 + MockWebSocket.instances[0]._message({ + type: 'freshAgent.created', + requestId: 'fresh-agent-created-before-reconnect', + sessionId: 'codex-thread-created-before-reconnect', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + }) + MockWebSocket.instances[0]._close(1006, 'drop-after-fresh-agent-created') + + const p2 = c.connect() + MockWebSocket.instances[1]._open() + MockWebSocket.instances[1]._message({ type: 'ready' }) + await p2 + + const resent = MockWebSocket.instances[1].sent + .map((x) => JSON.parse(x)) + .filter((m) => m.type === 'freshAgent.create' && m.requestId === 'fresh-agent-created-before-reconnect') + expect(resent).toHaveLength(0) + }) + it('clears sdk.create reconnect tracking when sdk.create.failed arrives', async () => { const c = new WsClient('ws://example/ws') c.send({ @@ -346,6 +409,73 @@ describe('WsClient.connect', () => { expect(resent).toHaveLength(0) }) + it('clears freshAgent.create reconnect tracking when freshAgent.create.failed arrives', async () => { + const c = new WsClient('ws://example/ws') + c.send({ + type: 'freshAgent.create', + requestId: 'fresh-agent-create-failed-before-reconnect', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/tmp/project', + } as any) + + const p1 = c.connect() + MockWebSocket.instances[0]._open() + MockWebSocket.instances[0]._message({ type: 'ready' }) + await p1 + MockWebSocket.instances[0]._message({ + type: 'freshAgent.create.failed', + requestId: 'fresh-agent-create-failed-before-reconnect', + code: 'FRESH_AGENT_CREATE_FAILED', + message: 'failed before reconnect', + retryable: true, + }) + MockWebSocket.instances[0]._close(1006, 'drop-after-fresh-agent-create-failed') + + const p2 = c.connect() + MockWebSocket.instances[1]._open() + MockWebSocket.instances[1]._message({ type: 'ready' }) + await p2 + + const resent = MockWebSocket.instances[1].sent + .map((x) => JSON.parse(x)) + .filter((m) => m.type === 'freshAgent.create' && m.requestId === 'fresh-agent-create-failed-before-reconnect') + expect(resent).toHaveLength(0) + }) + + it('does not flush or resend a cancelled freshAgent.create', async () => { + const c = new WsClient('ws://example/ws') + c.send({ + type: 'freshAgent.create', + requestId: 'fresh-agent-cancelled-before-connect', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/tmp/project', + } as any) + c.cancelCreate('fresh-agent-cancelled-before-connect') + + const p1 = c.connect() + MockWebSocket.instances[0]._open() + MockWebSocket.instances[0]._message({ type: 'ready' }) + await p1 + + const firstSent = MockWebSocket.instances[0].sent + .map((x) => JSON.parse(x)) + .filter((m) => m.type === 'freshAgent.create' && m.requestId === 'fresh-agent-cancelled-before-connect') + expect(firstSent).toHaveLength(0) + + MockWebSocket.instances[0]._close(1006, 'drop-after-cancelled-create') + const p2 = c.connect() + MockWebSocket.instances[1]._open() + MockWebSocket.instances[1]._message({ type: 'ready' }) + await p2 + + const resent = MockWebSocket.instances[1].sent + .map((x) => JSON.parse(x)) + .filter((m) => m.type === 'freshAgent.create' && m.requestId === 'fresh-agent-cancelled-before-connect') + expect(resent).toHaveLength(0) + }) + it('drops queued terminal.attach messages on reconnect so recovery only attaches once', async () => { const c = new WsClient('ws://example/ws') const reconnectHandler = vi.fn(() => { diff --git a/test/unit/client/store/browserPreferencesPersistence.test.ts b/test/unit/client/store/browserPreferencesPersistence.test.ts index 804e3af5f..9bcc2be90 100644 --- a/test/unit/client/store/browserPreferencesPersistence.test.ts +++ b/test/unit/client/store/browserPreferencesPersistence.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { configureStore } from '@reduxjs/toolkit' import settingsReducer, { setLocalSettings, updateSettingsLocal } from '@/store/settingsSlice' -import tabRegistryReducer, { setTabRegistrySearchRangeDays } from '@/store/tabRegistrySlice' +import tabRegistryReducer, { setTabRegistryClosedTabRetentionDays } from '@/store/tabRegistrySlice' import { BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS, browserPreferencesPersistenceMiddleware, @@ -36,7 +36,7 @@ describe('browserPreferencesPersistence', () => { localStorage.clear() }) - it('persists setLocalSettings and tab search range changes into the browser-preferences blob', () => { + it('persists setLocalSettings and closed tab retention changes into the browser-preferences blob', () => { const store = createStore() store.dispatch(setLocalSettings(resolveLocalSettings({ @@ -45,7 +45,7 @@ describe('browserPreferencesPersistence', () => { fontSize: 18, }, }))) - store.dispatch(setTabRegistrySearchRangeDays(90)) + store.dispatch(setTabRegistryClosedTabRetentionDays(14)) expect(localStorage.getItem(BROWSER_PREFERENCES_STORAGE_KEY)).toBeNull() @@ -59,7 +59,7 @@ describe('browserPreferencesPersistence', () => { }, }, tabs: { - searchRangeDays: 90, + closedTabRetentionDays: 14, }, }) }) diff --git a/test/unit/client/store/crossTabSync.test.ts b/test/unit/client/store/crossTabSync.test.ts index 98d4b7fcb..dfbc08b77 100644 --- a/test/unit/client/store/crossTabSync.test.ts +++ b/test/unit/client/store/crossTabSync.test.ts @@ -3,16 +3,22 @@ import { configureStore } from '@reduxjs/toolkit' import tabsReducer, { hydrateTabs } from '../../../../src/store/tabsSlice' import panesReducer, { hydratePanes } from '../../../../src/store/panesSlice' +import tabRecencyReducer from '../../../../src/store/tabRecencySlice' import settingsReducer, { setLocalSettings, updateSettingsLocal } from '../../../../src/store/settingsSlice' import tabRegistryReducer, { setTabRegistrySearchRangeDays } from '../../../../src/store/tabRegistrySlice' import { installCrossTabSync } from '../../../../src/store/crossTabSync' +import { + persistMiddleware, + PERSIST_DEBOUNCE_MS, + resetPersistFlushListenersForTests, +} from '../../../../src/store/persistMiddleware' import { BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS, browserPreferencesPersistenceMiddleware, resetBrowserPreferencesFlushListenersForTests, } from '../../../../src/store/browserPreferencesPersistence' import { broadcastPersistedRaw, resetPersistBroadcastForTests } from '../../../../src/store/persistBroadcast' -import { BROWSER_PREFERENCES_STORAGE_KEY, LAYOUT_STORAGE_KEY } from '../../../../src/store/storage-keys' +import { BROWSER_PREFERENCES_STORAGE_KEY, LAYOUT_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from '../../../../src/store/storage-keys' import { resolveLocalSettings } from '@shared/settings' import { sessionMetadataKey } from '@/lib/session-metadata' @@ -22,7 +28,9 @@ describe('crossTabSync', () => { afterEach(() => { vi.useRealTimers() localStorage.clear() + vi.restoreAllMocks() resetBrowserPreferencesFlushListenersForTests() + resetPersistFlushListenersForTests() resetPersistBroadcastForTests() for (const cleanup of cleanups.splice(0)) cleanup() }) @@ -117,6 +125,63 @@ describe('crossTabSync', () => { expect(store.getState().panes.activePane['tab-1']).toBe('pane-a') }) + it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-agent pane', () => { + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer }, + }) + + store.dispatch(hydratePanes({ + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-a', + status: 'idle', + resumeSessionId: '123e4567-e89b-12d3-a456-426614174000', + }, + } as any, + }, + activePane: { 'tab-1': 'pane-a' }, + paneTitles: {}, + })) + + cleanups.push(installCrossTabSync(store as any)) + + const remoteRaw = JSON.stringify({ + version: 3, + tabs: { activeTabId: null, tabs: [] }, + panes: { + version: 6, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-a', + status: 'idle', + resumeSessionId: 'not-a-canonical-id', + }, + }, + }, + activePane: { 'tab-1': 'pane-a' }, + paneTitles: {}, + }, + tombstones: [], + }) + + window.dispatchEvent(new StorageEvent('storage', { key: LAYOUT_STORAGE_KEY, newValue: remoteRaw })) + + const layout = store.getState().panes.layouts['tab-1'] as any + expect(layout.content.resumeSessionId).toBe('123e4567-e89b-12d3-a456-426614174000') + }) + it('dedupes identical persisted payloads delivered via both storage and BroadcastChannel', () => { const dispatchSpy = vi.fn() const storeLike = { @@ -173,9 +238,6 @@ describe('crossTabSync', () => { sortMode: 'project', }, }, - tabs: { - searchRangeDays: 365, - }, }) window.dispatchEvent(new StorageEvent('storage', { @@ -185,7 +247,7 @@ describe('crossTabSync', () => { expect(store.getState().settings.localSettings.theme).toBe('dark') expect(store.getState().settings.settings.sidebar.sortMode).toBe('project') - expect(store.getState().tabRegistry.searchRangeDays).toBe(365) + expect(store.getState().tabRegistry.searchRangeDays).toBe(30) }) it('hydrates browser-preference changes from BroadcastChannel messages', () => { @@ -224,7 +286,7 @@ describe('crossTabSync', () => { }) expect(store.getState().settings.settings.theme).toBe('dark') - expect(store.getState().tabRegistry.searchRangeDays).toBe(90) + expect(store.getState().tabRegistry.searchRangeDays).toBe(30) } finally { ;(globalThis as any).BroadcastChannel = original } @@ -291,7 +353,7 @@ describe('crossTabSync', () => { })) expect(store.getState().settings.settings.theme).toBe('dark') - expect(store.getState().tabRegistry.searchRangeDays).toBe(365) + expect(store.getState().tabRegistry.searchRangeDays).toBe(30) }) it('applies sparse browser-preference resets when previously persisted settings or search range are removed', () => { @@ -324,6 +386,165 @@ describe('crossTabSync', () => { expect(store.getState().tabRegistry.searchRangeDays).toBe(30) }) + it('merges tab recency sidecar events without rewriting layout or echoing the sidecar', () => { + vi.useFakeTimers() + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer, tabRecency: tabRecencyReducer }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + + store.dispatch({ + ...hydrateTabs({ + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Tab 1', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + } as any), + meta: { skipPersist: true }, + }) + store.dispatch({ + ...hydratePanes({ + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + } as any, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + paneTitleSetByUser: {}, + } as any), + meta: { skipPersist: true }, + }) + + cleanups.push(installCrossTabSync(store as any)) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + window.dispatchEvent(new StorageEvent('storage', { + key: TAB_RECENCY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_059_999, + }, + }), + })) + + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + }) + + it('merges tab recency sidecars by max and persists pruned local terminal panes', () => { + vi.useFakeTimers() + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer, tabRecency: tabRecencyReducer }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + preloadedState: { + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Tab 1', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-1': { + type: 'split', + id: 'root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-local', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-local', status: 'running' }, + }, + { + type: 'split', + id: 'right', + direction: 'vertical', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-shared', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-shared', status: 'running' }, + }, + { + type: 'leaf', + id: 'pane-remote', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-remote', status: 'running' }, + }, + ], + }, + ], + } as any, + }, + activePane: { 'tab-1': 'pane-local' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + }, + } as any, + }) + + cleanups.push(installCrossTabSync(store as any)) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + window.dispatchEvent(new StorageEvent('storage', { + key: TAB_RECENCY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-shared': 1_740_000_000_000, + 'pane-remote': 1_740_000_060_000, + 'pane-stale': 1_740_000_180_000, + }, + }), + })) + + expect(store.getState().tabRecency.paneLastInputAt).toEqual({ + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + it('merges remote browser-preference writes without clobbering dirty local settings', () => { vi.useFakeTimers() @@ -355,7 +576,7 @@ describe('crossTabSync', () => { expect(store.getState().settings.settings.theme).toBe('dark') expect(store.getState().settings.settings.sidebar.sortMode).toBe('project') - expect(store.getState().tabRegistry.searchRangeDays).toBe(365) + expect(store.getState().tabRegistry.searchRangeDays).toBe(30) vi.advanceTimersByTime(BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS) @@ -366,9 +587,6 @@ describe('crossTabSync', () => { sortMode: 'project', }, }, - tabs: { - searchRangeDays: 365, - }, }) }) @@ -403,7 +621,7 @@ describe('crossTabSync', () => { expect(store.getState().settings.settings.theme).toBe('dark') expect(store.getState().settings.settings.sidebar.sortMode).toBe('project') - expect(store.getState().tabRegistry.searchRangeDays).toBe(365) + expect(store.getState().tabRegistry.searchRangeDays).toBe(30) vi.advanceTimersByTime(BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS) @@ -414,9 +632,6 @@ describe('crossTabSync', () => { sortMode: 'project', }, }, - tabs: { - searchRangeDays: 365, - }, }) }) diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts new file mode 100644 index 000000000..f85ad4203 --- /dev/null +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import reducer, { + createFailed, + registerPendingCreate, + sessionCreated, + sessionInit, +} from '@/store/freshAgentSlice' + +describe('freshAgentSlice', () => { + it('tracks pending creates and resolves them into sessions', () => { + let state = reducer(undefined, registerPendingCreate({ + requestId: 'req-1', + sessionType: 'freshclaude', + provider: 'claude', + expectsHistoryHydration: false, + })) + + state = reducer(state, sessionCreated({ + requestId: 'req-1', + sessionId: 'sess-1', + sessionType: 'freshclaude', + provider: 'claude', + })) + state = reducer(state, sessionInit({ + sessionId: 'sess-1', + sessionType: 'freshclaude', + provider: 'claude', + cliSessionId: '00000000-0000-4000-8000-000000000111', + model: 'claude-opus-4-6', + })) + + expect(state.pendingCreates['req-1']).toMatchObject({ sessionId: 'sess-1' }) + expect(state.sessions['freshclaude:claude:sess-1']).toMatchObject({ + sessionId: 'sess-1', + sessionKey: 'freshclaude:claude:sess-1', + cliSessionId: '00000000-0000-4000-8000-000000000111', + model: 'claude-opus-4-6', + }) + }) + + it('stores request-scoped create failures without mutating unrelated sessions', () => { + const state = reducer(undefined, createFailed({ + requestId: 'req-2', + code: 'BROKEN', + message: 'Create failed', + retryable: true, + })) + + expect(state.pendingCreateFailures['req-2']).toEqual({ + code: 'BROKEN', + message: 'Create failed', + retryable: true, + }) + expect(state.sessions).toEqual({}) + }) +}) diff --git a/test/unit/client/store/panesPersistence.test.ts b/test/unit/client/store/panesPersistence.test.ts index 94a1bf18f..026478528 100644 --- a/test/unit/client/store/panesPersistence.test.ts +++ b/test/unit/client/store/panesPersistence.test.ts @@ -954,7 +954,7 @@ describe('legacy agent-chat display settings migration', () => { const bp = JSON.parse(localStorage.getItem(BROWSER_PREFERENCES_STORAGE_KEY) || '{}') expect(bp.settings.theme).toBe('dark') - expect(bp.tabs.searchRangeDays).toBe(60) + expect(bp.tabs.closedTabRetentionDays).toBe(30) expect(bp.settings.agentChat.showThinking).toBe(true) }) }) diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts index 00a576db6..80ea0249b 100644 --- a/test/unit/client/store/panesSlice.test.ts +++ b/test/unit/client/store/panesSlice.test.ts @@ -356,7 +356,7 @@ describe('panesSlice', () => { } }) - it('does not synthesize canonical sessionRef from raw agent-chat resumeSessionId', () => { + it('normalizes legacy agent-chat freshclaude input with a canonical Claude id to fresh-agent', () => { const state = panesReducer( initialState, initLayout({ @@ -370,19 +370,49 @@ describe('panesSlice', () => { ) const leaf = state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> - if (leaf.content.kind !== 'agent-chat') throw new Error('expected agent-chat') + expect(leaf.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: VALID_CLAUDE_SESSION_ID, + sessionRef: { + provider: 'claude', + sessionId: VALID_CLAUDE_SESSION_ID, + }, + }) + }) - expect(leaf.content.resumeSessionId).toBe(VALID_CLAUDE_SESSION_ID) + it('does not synthesize a portable sessionRef from a named legacy resume alias', () => { + const state = panesReducer( + initialState, + initLayout({ + tabId: 'tab-1', + content: { + kind: 'agent-chat', + provider: 'freshclaude', + resumeSessionId: 'named-resume', + }, + }), + ) + + const leaf = state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> + expect(leaf.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: 'named-resume', + }) expect(leaf.content.sessionRef).toBeUndefined() }) }) describe('restartAgentChatCreate', () => { - it('moves an agent-chat pane into stable create-failed state until an explicit retry restarts it', () => { + it('moves a fresh-agent pane into stable create-failed state until an explicit retry restarts it', () => { const state = panesReducer( stateWithLeaf('pane-agent', { - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', createRequestId: 'req-1', status: 'create-failed' as any, createError: { @@ -396,10 +426,10 @@ describe('panesSlice', () => { const layout = state.layouts['tab-1'] as Extract<PaneNode, { type: 'leaf' }> expect(layout.content).toMatchObject({ - kind: 'agent-chat', + kind: 'fresh-agent', status: 'creating', }) - if (layout.content.kind === 'agent-chat') { + if (layout.content.kind === 'fresh-agent') { expect((layout.content as any).createError).toBeUndefined() expect(layout.content.createRequestId).not.toBe('req-1') } diff --git a/test/unit/client/store/persisted-state.fresh-agent.test.ts b/test/unit/client/store/persisted-state.fresh-agent.test.ts new file mode 100644 index 000000000..fb55587f0 --- /dev/null +++ b/test/unit/client/store/persisted-state.fresh-agent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { parsePersistedPanesRaw } from '@/store/persistedState' + +function findLeafContent(node: any): any { + if (!node || typeof node !== 'object') return undefined + if (node.type === 'leaf') return node.content + if (node.type === 'split' && Array.isArray(node.children)) { + return findLeafContent(node.children[0]) ?? findLeafContent(node.children[1]) + } + return undefined +} + +describe('persistedState fresh-agent migration', () => { + it('migrates persisted agent-chat panes to fresh-agent panes', () => { + const parsed = parsePersistedPanesRaw(JSON.stringify({ + version: 6, + layouts: { + tab_1: { + type: 'leaf', + id: 'pane_1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + })) + + expect(findLeafContent(parsed!.layouts.tab_1)).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) +}) diff --git a/test/unit/client/store/persistedState.test.ts b/test/unit/client/store/persistedState.test.ts index f85470e16..6bff37372 100644 --- a/test/unit/client/store/persistedState.test.ts +++ b/test/unit/client/store/persistedState.test.ts @@ -11,6 +11,18 @@ import { import { PERSIST_BROADCAST_CHANNEL_NAME } from '../../../../src/store/persistBroadcast' import { STORAGE_KEYS } from '../../../../src/store/storage-keys' +const codexDurability = { + schemaVersion: 1 as const, + state: 'captured_pre_turn' as const, + candidate: { + provider: 'codex' as const, + candidateThreadId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + source: 'thread_started_notification' as const, + capturedAt: 1778743920000, + }, +} + describe('persistedState parsers', () => { it('uses v2 namespaced storage and broadcast keys', () => { expect(TABS_STORAGE_KEY).toBe('freshell.tabs.v2') @@ -41,6 +53,26 @@ describe('persistedState parsers', () => { expect(parsed!.version).toBe(0) expect(parsed!.tabs.tabs[0].id).toBe('t1') }) + + it('preserves valid Codex durability state on tabs', () => { + const raw = JSON.stringify({ + version: TABS_SCHEMA_VERSION, + tabs: { + activeTabId: 't1', + tabs: [{ + id: 't1', + title: 'Codex', + createdAt: 1, + type: 'terminal', + mode: 'codex', + codexDurability, + }], + }, + }) + + const parsed = parsePersistedTabsRaw(raw) + expect(parsed?.tabs.tabs[0].codexDurability).toEqual(codexDurability) + }) }) describe('parsePersistedPanesRaw', () => { @@ -77,6 +109,33 @@ describe('persistedState parsers', () => { expect(Object.keys(parsed!.layouts)).toEqual(['tab-1']) }) + it('preserves valid Codex durability state on terminal pane content', () => { + const raw = JSON.stringify({ + version: PANES_SCHEMA_VERSION, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + createRequestId: 'req-1', + status: 'creating', + mode: 'codex', + shell: 'system', + codexDurability, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }) + + const parsed = parsePersistedPanesRaw(raw) + const content = (parsed!.layouts['tab-1'] as any).content + expect(content.codexDurability).toEqual(codexDurability) + }) + it('normalizes legacy Codex recovery_failed panes to creating resume panes', () => { const parsed = parsePersistedPanesRaw(JSON.stringify({ version: 1, diff --git a/test/unit/client/store/selectors/paneTerminalSelectors.test.ts b/test/unit/client/store/selectors/paneTerminalSelectors.test.ts index f5cc24507..c7bdbb5fb 100644 --- a/test/unit/client/store/selectors/paneTerminalSelectors.test.ts +++ b/test/unit/client/store/selectors/paneTerminalSelectors.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest' import type { PaneNode } from '@/store/paneTypes' import { + selectTabPaneByTerminalId, selectTerminalIdsForTab, selectPrimaryTerminalIdForTab, selectTabIdByTerminalId, @@ -48,8 +49,12 @@ function makeSplit(left: PaneNode, right: PaneNode): PaneNode { function makeState(overrides: { layouts?: Record<string, PaneNode> activePane?: Record<string, string> + activeTabId?: string | null }) { return { + tabs: { + activeTabId: overrides.activeTabId ?? null, + }, panes: { layouts: overrides.layouts ?? {}, activePane: overrides.activePane ?? {}, @@ -108,6 +113,41 @@ describe('selectTerminalIdsForTab', () => { }) }) +describe('selectTabPaneByTerminalId', () => { + it('prefers the active tab when the same running terminal is present in multiple tabs', () => { + const state = makeState({ + activeTabId: 'tab-active', + layouts: { + 'tab-background': makeLeaf('pane-background', 'term-shared'), + 'tab-active': makeLeaf('pane-active', 'term-shared'), + }, + activePane: { + 'tab-active': 'pane-active', + }, + }) + + expect(selectTabPaneByTerminalId(state, 'term-shared')).toEqual({ + tabId: 'tab-active', + paneId: 'pane-active', + }) + }) + + it('falls back to the first matching pane when the active tab does not contain the terminal', () => { + const state = makeState({ + activeTabId: 'tab-other', + layouts: { + 'tab-background': makeLeaf('pane-background', 'term-shared'), + 'tab-other': makeLeaf('pane-other', 'term-other'), + }, + }) + + expect(selectTabPaneByTerminalId(state, 'term-shared')).toEqual({ + tabId: 'tab-background', + paneId: 'pane-background', + }) + }) +}) + describe('selectPrimaryTerminalIdForTab', () => { it('returns undefined when no layout exists', () => { const state = makeState({}) diff --git a/test/unit/client/store/selectors/sidebarSelectors.runningTerminal.test.ts b/test/unit/client/store/selectors/sidebarSelectors.runningTerminal.test.ts index a07924a0d..d05937430 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.runningTerminal.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.runningTerminal.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { makeSelectSortedSessionItems } from '@/store/selectors/sidebarSelectors' +import { + buildSessionItems, + filterSessionItemsByVisibility, + makeSelectSortedSessionItems, +} from '@/store/selectors/sidebarSelectors' import type { BackgroundTerminal } from '@/store/types' import type { RootState } from '@/store/store' @@ -70,6 +74,12 @@ function createState(): RootState { } describe('sidebarSelectors running session mapping', () => { + const emptyPanes = { + layouts: {}, + activePaneByTabId: {}, + paneTitles: {}, + } as any + it('pins session runningTerminalId to the oldest running terminal when duplicate mappings exist', () => { const selector = makeSelectSortedSessionItems() const state = createState() @@ -107,4 +117,120 @@ describe('sidebarSelectors running session mapping', () => { expect(items).toHaveLength(1) expect(items[0].runningTerminalId).toBe('older-terminal') }) + + it('uses server session-directory running state when terminal directory has no sessionRef yet', () => { + const items = buildSessionItems([ + { + projectPath: '/repo/live', + sessions: [{ + provider: 'codex', + sessionId: 'codex-live-1', + projectPath: '/repo/live', + lastActivityAt: 1_700, + title: 'Live Codex', + isRunning: true, + runningTerminalId: 'term-codex-1', + }], + }, + ] as any, [], emptyPanes, [], {}, 'repo') + + expect(items[0]).toMatchObject({ + isRunning: true, + runningTerminalId: 'term-codex-1', + hasTab: false, + }) + expect(items[0].runningTerminalIds).toBeUndefined() + }) + + it('does not hide titleless running sessions when hideEmptySessions is enabled', () => { + const items = buildSessionItems([ + { + projectPath: '/repo/live', + sessions: [{ + provider: 'opencode', + sessionId: 'ses_live_opencode', + projectPath: '/repo/live', + lastActivityAt: 1_800, + isRunning: true, + runningTerminalId: 'term-opencode-1', + }], + }, + ] as any, [], emptyPanes, [], {}, 'repo') + + const visible = filterSessionItemsByVisibility(items, { + showSubagents: true, + ignoreCodexSubagents: false, + showNoninteractiveSessions: true, + hideEmptySessions: true, + excludeFirstChatSubstrings: [], + excludeFirstChatMustStart: false, + }) + + expect(visible.map((item) => item.sessionId)).toEqual(['ses_live_opencode']) + }) + + it('adds a live-only item for running coding terminals without session refs', () => { + const items = buildSessionItems( + [], + [{ + id: 'tab-opencode', + title: 'OpenCode', + mode: 'opencode', + createRequestId: 'tab-opencode', + status: 'running', + createdAt: 1_000, + }] as any, + { + layouts: { + 'tab-opencode': { + type: 'leaf', + id: 'pane-opencode', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'term-opencode-live', + status: 'running', + }, + }, + }, + activePaneByTabId: { + 'tab-opencode': 'pane-opencode', + }, + activePane: { + 'tab-opencode': 'pane-opencode', + }, + paneTitles: { + 'tab-opencode': { + 'pane-opencode': 'OpenCode', + }, + }, + } as any, + [{ + terminalId: 'term-opencode-live', + title: 'OpenCode', + createdAt: 1_100, + lastActivityAt: 1_200, + status: 'running', + hasClients: true, + mode: 'opencode', + cwd: '/repo/live', + }], + {}, + 'repo', + ) + + expect(items).toEqual([ + expect.objectContaining({ + provider: 'opencode', + sessionId: 'terminal:term-opencode-live', + title: 'OpenCode', + subtitle: 'live', + hasTab: true, + isRunning: true, + runningTerminalId: 'term-opencode-live', + runningTerminalIds: ['term-opencode-live'], + liveTerminalOnly: true, + }), + ]) + }) }) diff --git a/test/unit/client/store/selectors/sidebarSelectors.test.ts b/test/unit/client/store/selectors/sidebarSelectors.test.ts index 67ad7fc04..feda345cd 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import type { SidebarSessionItem } from '@/store/selectors/sidebarSelectors' -import type { ProjectGroup, CodingCliSession } from '@/store/types' +import type { ProjectGroup, CodingCliSession, BackgroundTerminal } from '@/store/types' import { buildSessionItems, @@ -66,6 +66,7 @@ function createSelectorState(options: { appliedQuery?: string appliedSearchTier?: 'title' | 'userMessages' | 'fullText' sessionActivity?: Record<string, number> + tabRecency?: { paneLastInputAt: Record<string, number> } } = {}) { const projects = options.projects ?? [] return { @@ -105,6 +106,9 @@ function createSelectorState(options: { sessionActivity: { sessions: options.sessionActivity ?? {}, }, + tabRecency: options.tabRecency ?? { + paneLastInputAt: {}, + }, } as any } @@ -390,6 +394,163 @@ describe('sidebarSelectors', () => { ]) }) + it('shows running Codex terminals with captured identity as non-restorable live rows', () => { + const terminals: BackgroundTerminal[] = [ + { + terminalId: 'term-codex-a', + title: 'Codex CLI', + createdAt: 2_000, + lastActivityAt: 2_100, + status: 'running', + hasClients: true, + cwd: '/repo', + mode: 'codex', + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-candidate', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'thread_start_response', + capturedAt: 2_000, + }, + }, + }, + { + terminalId: 'term-codex-b', + title: 'Codex CLI', + createdAt: 2_050, + lastActivityAt: 2_200, + status: 'running', + hasClients: false, + cwd: '/repo', + mode: 'codex', + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-candidate', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'thread_start_response', + capturedAt: 2_000, + }, + }, + }, + ] + + const items = buildSessionItems([], emptyTabs, emptyPanes, terminals, emptyActivity) + + expect(items).toEqual([ + expect.objectContaining({ + sessionId: 'thread-candidate', + provider: 'codex', + title: 'Codex CLI', + cwd: '/repo', + hasTab: false, + isRunning: true, + runningTerminalId: 'term-codex-a', + runningTerminalIds: ['term-codex-a', 'term-codex-b'], + isRestorable: false, + codexDurabilityState: 'captured_pre_turn', + isFallback: true, + }), + ]) + }) + + it('shows durable Codex terminal identity as restorable even before the server window includes history', () => { + const terminals: BackgroundTerminal[] = [ + { + terminalId: 'term-codex-durable', + title: 'Codex CLI', + createdAt: 2_000, + lastActivityAt: 2_100, + status: 'running', + hasClients: true, + cwd: '/repo', + mode: 'codex', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-durable', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-durable', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'thread_start_response', + capturedAt: 2_000, + }, + turnCompletedAt: 2_050, + }, + }, + ] + + const items = buildSessionItems([], emptyTabs, emptyPanes, terminals, emptyActivity) + + expect(items).toEqual([ + expect.objectContaining({ + sessionId: 'thread-durable', + provider: 'codex', + hasTab: false, + isRunning: true, + runningTerminalId: 'term-codex-durable', + isRestorable: true, + codexDurabilityState: 'durable', + }), + ]) + }) + + it('shows persisted Codex pane identity without treating it as a durable resume target', () => { + const tabs = [ + { id: 'tab-codex', title: 'Current Codex', mode: 'codex', createdAt: 2_000 }, + ] as any + const panes = { + layouts: { + 'tab-codex': { + type: 'leaf', + id: 'pane-codex', + content: { + kind: 'terminal', + mode: 'codex', + status: 'running', + createRequestId: 'req-codex', + initialCwd: '/repo', + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-pre-durable', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'restored_client_state', + capturedAt: 2_000, + }, + }, + }, + }, + }, + activePane: { + 'tab-codex': 'pane-codex', + }, + } as any + + const items = buildSessionItems([], tabs, panes, emptyTerminals, emptyActivity) + + expect(items).toEqual([ + expect.objectContaining({ + sessionId: 'thread-pre-durable', + provider: 'codex', + title: 'Current Codex', + cwd: '/repo', + hasTab: true, + isRunning: false, + isRestorable: false, + codexDurabilityState: 'captured_pre_turn', + }), + ]) + }) + it('marks synthesized rows as fallback-only while leaving server-backed rows unmarked', () => { const fallback = createFallbackTab('tab-restored', 'codex-restored', 'Restored Session', '/tmp/restored-project') const items = buildSessionItems( @@ -559,6 +720,42 @@ describe('sidebarSelectors', () => { }), ]) }) + + it('uses minute-bucketed pane activity for open-tab fallback timestamps', () => { + const fallback = createFallbackTab('tab-restored', 'codex-restored', 'Restored Session', '/tmp/restored-project') + const selectSortedItems = makeSelectSortedSessionItems() + + const items = selectSortedItems(createSelectorState({ + tabs: [{ + ...fallback.tab, + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + }], + panes: { + layouts: { + [fallback.tab.id]: fallback.layout, + }, + activePane: { + [fallback.tab.id]: fallback.paneId, + }, + paneTitles: { + [fallback.tab.id]: { [fallback.paneId]: fallback.tab.title }, + }, + }, + tabRecency: { + paneLastInputAt: { + [fallback.paneId]: 1_740_000_080_000, + }, + }, + }), [], '') + + expect(items).toEqual([ + expect.objectContaining({ + sessionId: 'codex-restored', + timestamp: 1_740_000_060_000, + }), + ]) + }) }) describe('worktree grouping', () => { diff --git a/test/unit/client/store/sessionsThunks.test.ts b/test/unit/client/store/sessionsThunks.test.ts index f7369a3b6..49310500c 100644 --- a/test/unit/client/store/sessionsThunks.test.ts +++ b/test/unit/client/store/sessionsThunks.test.ts @@ -199,6 +199,40 @@ describe('sessionsThunks', () => { expect((store.getState().sessions.windows.sidebar as any).loadingKind).toBeUndefined() }) + it('preserves running state when grouping search results into projects', async () => { + searchSessions.mockResolvedValueOnce({ + results: [{ + provider: 'opencode', + sessionId: 'ses-live-opencode', + projectPath: '/tmp/project-live', + title: 'Live OpenCode', + matchedIn: 'title', + lastActivityAt: 1_800, + isRunning: true, + runningTerminalId: 'term-opencode-1', + }], + tier: 'title', + query: 'live', + totalScanned: 1, + }) + + const store = createStore() + store.dispatch(setActiveSessionSurface('sidebar')) + + await store.dispatch(fetchSessionWindow({ + surface: 'sidebar', + priority: 'visible', + query: 'live', + }) as any) + + expect(store.getState().sessions.windows.sidebar.projects[0]?.sessions[0]).toMatchObject({ + provider: 'opencode', + sessionId: 'ses-live-opencode', + isRunning: true, + runningTerminalId: 'term-opencode-1', + }) + }) + it('keeps tier changes and clearing a non-empty query in the visible search intent', async () => { const tierChange = createDeferred<any>() const clearSearch = createDeferred<any>() diff --git a/test/unit/client/store/settingsSlice.test.ts b/test/unit/client/store/settingsSlice.test.ts index b06516f1f..d7e903ba3 100644 --- a/test/unit/client/store/settingsSlice.test.ts +++ b/test/unit/client/store/settingsSlice.test.ts @@ -77,7 +77,10 @@ describe('settingsSlice', () => { const state = settingsReducer(initialState, setServerSettings(nextServerSettings)) expect(state.loaded).toBe(true) - expect(state.serverSettings).toEqual(nextServerSettings) + expect(state.serverSettings.defaultCwd).toBe('/workspace') + expect(state.serverSettings.terminal.scrollback).toBe(12000) + expect(state.serverSettings.freshAgent.defaultPlugins).toEqual([]) + expect(state.serverSettings.agentChat.defaultPlugins).toEqual([]) expect(state.settings).toEqual({ ...defaultSettings, defaultCwd: '/workspace', @@ -85,9 +88,13 @@ describe('settingsSlice', () => { ...defaultSettings.terminal, scrollback: 12000, }, + freshAgent: { + ...defaultSettings.freshAgent, + defaultPlugins: [], + }, agentChat: { ...defaultSettings.agentChat, - defaultPlugins: ['fs'], + defaultPlugins: [], }, }) }) diff --git a/test/unit/client/store/settingsThunks.test.ts b/test/unit/client/store/settingsThunks.test.ts index 77fe85c68..5e2172613 100644 --- a/test/unit/client/store/settingsThunks.test.ts +++ b/test/unit/client/store/settingsThunks.test.ts @@ -411,7 +411,7 @@ describe('settingsThunks', () => { providers: { ...initialServerSettings.agentChat.providers, freshclaude: { - modelSelection: { kind: 'tracked', modelId: 'opus[1m]' }, + modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' }, effort: 'turbo', }, }, diff --git a/test/unit/client/store/state-edge-cases.test.ts b/test/unit/client/store/state-edge-cases.test.ts index 7e85e3417..f00f605a3 100644 --- a/test/unit/client/store/state-edge-cases.test.ts +++ b/test/unit/client/store/state-edge-cases.test.ts @@ -857,6 +857,11 @@ describe('State Edge Cases', () => { defaultPlugins: ['fs'], providers: {}, }, + freshAgent: { + ...defaultSettings.freshAgent, + defaultPlugins: ['fs'], + providers: {}, + }, extensions: { ...defaultSettings.extensions, }, diff --git a/test/unit/client/store/storage-migration.fresh-agent.test.ts b/test/unit/client/store/storage-migration.fresh-agent.test.ts new file mode 100644 index 000000000..3f858baa3 --- /dev/null +++ b/test/unit/client/store/storage-migration.fresh-agent.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const localStorageMock = (() => { + let store: Record<string, string> = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +describe('storage-migration fresh-agent', () => { + beforeEach(() => { + vi.resetModules() + localStorageMock.clear() + Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + }) + + it('does not clear freshell layout storage during the fresh-agent migration', async () => { + localStorage.setItem('freshell.layout.v3', JSON.stringify({ + version: 3, + tabs: { tabs: [], activeTabId: null }, + panes: { + version: 6, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + + const module = await import('@/store/storage-migration') + module.runStorageMigration() + + const raw = localStorage.getItem('freshell.layout.v3') + expect(raw).not.toBeNull() + const parsed = JSON.parse(raw!) + expect(parsed.panes.layouts['tab-1'].content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + }) + }) +}) diff --git a/test/unit/client/store/storage-migration.test.ts b/test/unit/client/store/storage-migration.test.ts index 75860047d..5a9aed39c 100644 --- a/test/unit/client/store/storage-migration.test.ts +++ b/test/unit/client/store/storage-migration.test.ts @@ -53,7 +53,7 @@ describe('storage-migration', () => { })) expect(localStorage.getItem('freshell.tabs.v1')).toBeNull() expect(localStorage.getItem('freshell.panes.v1')).toBeNull() - expect(localStorage.getItem('freshell_version')).toBe('4') + expect(localStorage.getItem('freshell_version')).toBe('5') }) it('clears stale freshell-auth cookie when no auth token remains', async () => { @@ -92,7 +92,7 @@ describe('storage-migration', () => { })) expect(localStorage.getItem('freshell.terminal.fontFamily.v1')).toBeNull() expect(localStorage.getItem('freshell.tabs.v1')).toBeNull() - expect(localStorage.getItem('freshell_version')).toBe('4') + expect(localStorage.getItem('freshell_version')).toBe('5') }) it('preserves restorable layouts and migrates ambiguous resume ids instead of clearing state', async () => { @@ -158,7 +158,7 @@ describe('storage-migration', () => { const migratedRaw = localStorage.getItem(LAYOUT_STORAGE_KEY) expect(migratedRaw).not.toBeNull() - expect(localStorage.getItem('freshell_version')).toBe('4') + expect(localStorage.getItem('freshell_version')).toBe('5') const parsed = parsePersistedLayoutRaw(migratedRaw!) expect(parsed).not.toBeNull() diff --git a/test/unit/client/store/tabRecencyPruneMiddleware.test.ts b/test/unit/client/store/tabRecencyPruneMiddleware.test.ts new file mode 100644 index 000000000..2d8225950 --- /dev/null +++ b/test/unit/client/store/tabRecencyPruneMiddleware.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +import { tabRecencyPruneMiddleware } from '@/store/tabRecencyPruneMiddleware' + +describe('tabRecencyPruneMiddleware', () => { + it('does not inspect pane topology for unrelated actions', () => { + const action = { type: 'settings/updateSettingsLocal', payload: { theme: 'dark' } } + const store = { + getState: vi.fn(() => { + throw new Error('unrelated actions should not inspect recency topology') + }), + dispatch: vi.fn(), + } + const next = vi.fn((received) => received) + + const result = tabRecencyPruneMiddleware(store as any)(next)(action) + + expect(result).toBe(action) + expect(next).toHaveBeenCalledWith(action) + expect(store.getState).not.toHaveBeenCalled() + expect(store.dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/client/store/tabRecencySlice.test.ts b/test/unit/client/store/tabRecencySlice.test.ts new file mode 100644 index 000000000..316fee708 --- /dev/null +++ b/test/unit/client/store/tabRecencySlice.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import reducer, { + loadPersistedTabRecency, + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, + recordPaneTabActivity, + serializePersistableTabRecency, +} from '@/store/tabRecencySlice' + +describe('tabRecencySlice', () => { + it('stores pane activity at 60-second resolution', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + + expect(state.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + }) + + it('does not move a pane backward', () => { + const first = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_120_000, + })) + const second = reducer(first, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_060_000, + })) + + expect(second.paneLastInputAt['pane-1']).toBe(1_740_000_120_000) + }) + + it('records the zero minute bucket for deterministic tests and epoch data', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 0, + })) + + expect(state.paneLastInputAt['pane-1']).toBe(0) + }) + + it('ignores invalid pane ids and timestamps', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: '', + at: Number.NaN, + })) + + expect(state.paneLastInputAt).toEqual({}) + }) + + it('loads only valid persisted minute buckets', () => { + expect(loadPersistedTabRecency(JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + 'pane-2': 1_740_000_059_999, + bad: -1, + }, + }))).toEqual({ + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + 'pane-2': 1_740_000_000_000, + }, + }) + }) + + it('serializes only minute-bucketed recency values for live terminal panes', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + const withStalePane = { + paneLastInputAt: { + ...state.paneLastInputAt, + stale: 1_740_000_000_000, + 'pane-picker': 1_740_000_120_000, + }, + } + + expect(serializePersistableTabRecency(withStalePane, { + 'tab-1': { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-picker', + content: { kind: 'picker' }, + }, + ], + } as any, + 'closed-tab': { + type: 'leaf', + id: 'stale', + content: { kind: 'terminal' }, + } as any, + }, new Set(['tab-1']))).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + }) + + it('merges cross-window recency by per-pane max without dropping local panes', () => { + const state = reducer({ + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + }, mergeHydratedTabRecency({ + paneLastInputAt: { + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_000_000, + }, + })) + + expect(state).toEqual({ + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + + it('prunes live state to current terminal pane ids', () => { + const state = reducer({ + paneLastInputAt: { + 'pane-terminal': 1_740_000_000_000, + 'pane-replaced': 1_740_000_060_000, + }, + }, prunePaneTabActivityToLiveTerminalPanes({ + paneIds: ['pane-terminal'], + })) + + expect(state).toEqual({ + paneLastInputAt: { + 'pane-terminal': 1_740_000_000_000, + }, + }) + }) +}) diff --git a/test/unit/client/store/tabRegistrySlice.test.ts b/test/unit/client/store/tabRegistrySlice.test.ts index c19b45fa9..a3f0eacf0 100644 --- a/test/unit/client/store/tabRegistrySlice.test.ts +++ b/test/unit/client/store/tabRegistrySlice.test.ts @@ -18,6 +18,7 @@ import { DEVICE_LABEL_STORAGE_KEY, } from '../../../../src/store/storage-keys' import type { RegistryTabRecord } from '../../../../src/store/tabRegistryTypes' +import { selectTabsRegistryGroups } from '../../../../src/store/selectors/tabsRegistrySelectors' function makeRecord(overrides: Partial<RegistryTabRecord>): RegistryTabRecord { return { @@ -45,6 +46,7 @@ describe('tabRegistrySlice', () => { afterEach(() => { localStorage.clear() + vi.useRealTimers() }) it('uses v2 namespaced device storage keys', () => { @@ -148,7 +150,7 @@ describe('tabRegistrySlice', () => { }) }) - it('initializes searchRangeDays from browser preferences instead of always resetting to 30', async () => { + it('clamps legacy searchRangeDays from browser preferences to the closed retention limit', async () => { localStorage.setItem(BROWSER_PREFERENCES_STORAGE_KEY, JSON.stringify({ tabs: { searchRangeDays: 365, @@ -159,6 +161,92 @@ describe('tabRegistrySlice', () => { const freshModule = await import('../../../../src/store/tabRegistrySlice') const freshReducer = freshModule.default - expect(freshReducer(undefined, { type: 'unknown' }).searchRangeDays).toBe(365) + expect(freshReducer(undefined, { type: 'unknown' }).closedTabRetentionDays).toBe(30) + expect(freshReducer(undefined, { type: 'unknown' }).searchRangeDays).toBe(30) + }) + + it('filters local closed records by the selected closed retention window', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-07T12:00:00Z')) + const now = Date.now() + const oldClosed = makeRecord({ + tabKey: 'local:old', + tabId: 'old', + status: 'closed', + updatedAt: now - 10 * 24 * 60 * 60 * 1000, + closedAt: now - 10 * 24 * 60 * 60 * 1000, + }) + const freshClosed = makeRecord({ + tabKey: 'local:fresh', + tabId: 'fresh', + status: 'closed', + updatedAt: now - 2 * 24 * 60 * 60 * 1000, + closedAt: now - 2 * 24 * 60 * 60 * 1000, + }) + + const groups = selectTabsRegistryGroups({ + tabs: { tabs: [] }, + panes: { layouts: {}, paneTitles: {} }, + connection: { serverInstanceId: 'srv-test' }, + tabRegistry: { + deviceId: 'device-1', + deviceLabel: 'device-1', + sameDeviceOpen: [], + remoteOpen: [], + closed: [], + localClosed: { + [oldClosed.tabKey]: oldClosed, + [freshClosed.tabKey]: freshClosed, + }, + closedTabRetentionDays: 7, + searchRangeDays: 7, + }, + } as any) + + expect(groups.closed.map((record) => record.tabKey)).toEqual(['local:fresh']) + vi.useRealTimers() + }) + + it('derives local open tab recency from minute-bucketed pane activity', () => { + const result = selectTabsRegistryGroups({ + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Active Tab', + status: 'running', + mode: 'shell', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + }], + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + }, + }, + paneTitles: { 'tab-1': { 'pane-1': 'Shell' } }, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_080_000, + }, + }, + tabRegistry: { + deviceId: 'device-1', + deviceLabel: 'Device', + remoteOpen: [], + closed: [], + localClosed: {}, + }, + connection: { + serverInstanceId: 'srv-test', + }, + } as any) + + expect(result.localOpen[0].updatedAt).toBe(1_740_000_060_000) }) }) diff --git a/test/unit/client/store/tabRegistrySync.test.ts b/test/unit/client/store/tabRegistrySync.test.ts index bdc7eed23..252589a87 100644 --- a/test/unit/client/store/tabRegistrySync.test.ts +++ b/test/unit/client/store/tabRegistrySync.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { RootState } from '../../../../src/store/store' -import { startTabRegistrySync, SYNC_INTERVAL_MS } from '../../../../src/store/tabRegistrySync' +import { + CLIENT_LEASE_GRACE_MS, + getCurrentTabRegistryClientInstanceId, + HEARTBEAT_INTERVAL_MS, + startTabRegistrySync, + SYNC_INTERVAL_MS, +} from '../../../../src/store/tabRegistrySync' type Listener = () => void @@ -18,6 +24,7 @@ function createState(): RootState { }], activeTabId: 'tab-1', renameRequestTabId: null, + tombstones: [], }, panes: { layouts: { @@ -39,14 +46,21 @@ function createState(): RootState { renameRequestTabId: null, renameRequestPaneId: null, zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: {}, }, tabRegistry: { deviceId: 'local-device', deviceLabel: 'local-label', localOpen: [], + sameDeviceOpen: [], remoteOpen: [], closed: [], + devices: [], localClosed: {}, + closedTabRetentionDays: 30, searchRangeDays: 30, loading: false, }, @@ -66,18 +80,41 @@ describe('tabRegistrySync', () => { let state: RootState let dispatch: ReturnType<typeof vi.fn> let ws: any + let broadcastChannels: Array<{ + name: string + postMessage: ReturnType<typeof vi.fn> + close: ReturnType<typeof vi.fn> + onmessage: ((event: { data: any }) => void) | null + }> + + function createStore() { + return { + getState: () => state, + dispatch, + subscribe: (listener: Listener) => { + listeners.push(listener) + return () => { + listeners = listeners.filter((item) => item !== listener) + } + }, + } + } beforeEach(() => { vi.useFakeTimers() + vi.setSystemTime(new Date(1_740_000_000_000)) listeners = [] wsMessageHandlers = [] wsReconnectHandlers = [] + broadcastChannels = [] + sessionStorage.clear() state = createState() dispatch = vi.fn() ws = { state: 'ready', sendTabsSyncPush: vi.fn(), sendTabsSyncQuery: vi.fn(), + sendTabsSyncClientRetire: vi.fn(), onMessage: (handler: (msg: any) => void) => { wsMessageHandlers.push(handler) return () => { @@ -91,16 +128,28 @@ describe('tabRegistrySync', () => { } }, } - }) + class MockBroadcastChannel { + name: string + postMessage = vi.fn() + close = vi.fn() + onmessage: ((event: { data: any }) => void) | null = null - afterEach(() => { - vi.useRealTimers() + constructor(name: string) { + this.name = name + broadcastChannels.push(this) + } + } + vi.stubGlobal('BroadcastChannel', MockBroadcastChannel) + vi.stubGlobal('navigator', { + ...globalThis.navigator, + sendBeacon: vi.fn(() => true), + }) }) - it('pushes tabs.sync only when lifecycle changes', () => { - const store = { + function createStore(customDispatch = dispatch) { + return { getState: () => state, - dispatch, + dispatch: customDispatch, subscribe: (listener: Listener) => { listeners.push(listener) return () => { @@ -108,11 +157,32 @@ describe('tabRegistrySync', () => { } }, } + } + + afterEach(() => { + vi.unstubAllGlobals() + try { + sessionStorage.clear() + } catch { + // Tests that intentionally block sessionStorage restore globals above. + } + vi.useRealTimers() + }) + + it('pushes tabs.sync only when lifecycle changes', () => { + const store = createStore() const stop = startTabRegistrySync(store as any, ws) expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) - expect(ws.sendTabsSyncQuery.mock.calls[0][0].rangeDays).toBeUndefined() + expect(ws.sendTabsSyncQuery.mock.calls[0][0]).toMatchObject({ + clientInstanceId: expect.any(String), + closedTabRetentionDays: 30, + }) expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush.mock.calls[0][0]).toMatchObject({ + clientInstanceId: expect.any(String), + snapshotRevision: expect.any(Number), + }) ws.sendTabsSyncPush.mockClear() vi.advanceTimersByTime(SYNC_INTERVAL_MS) @@ -131,15 +201,64 @@ describe('tabRegistrySync', () => { stop() }) - it('includes expanded search range when querying snapshots', () => { + it('includes selected closed retention when querying snapshots', () => { state = { ...state, tabRegistry: { ...state.tabRegistry, - searchRangeDays: 90, + closedTabRetentionDays: 14, + searchRangeDays: 14, }, } + const store = createStore() + + const stop = startTabRegistrySync(store as any, ws) + expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncQuery.mock.calls[0][0].closedTabRetentionDays).toBe(14) + stop() + }) + + it('re-queries with the current closed retention after reconnect', () => { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + closedTabRetentionDays: 7, + searchRangeDays: 7, + }, + } + + const store = createStore() + + const stop = startTabRegistrySync(store as any, ws) + ws.sendTabsSyncQuery.mockClear() + + wsReconnectHandlers.forEach((handler) => handler()) + + expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncQuery.mock.calls[0][0].closedTabRetentionDays).toBe(7) + stop() + }) + + it('keeps one in-memory client id for push and direct query helpers when sessionStorage is unavailable', () => { + vi.unstubAllGlobals() + vi.stubGlobal('sessionStorage', { + getItem: vi.fn(() => { + throw new Error('blocked') + }), + setItem: vi.fn(() => { + throw new Error('blocked') + }), + clear: vi.fn(), + }) + vi.stubGlobal('BroadcastChannel', undefined) + vi.stubGlobal('navigator', { + ...globalThis.navigator, + sendBeacon: vi.fn(() => true), + }) + const firstClientId = getCurrentTabRegistryClientInstanceId() + expect(getCurrentTabRegistryClientInstanceId()).toBe(firstClientId) const store = { getState: () => state, dispatch, @@ -152,20 +271,209 @@ describe('tabRegistrySync', () => { } const stop = startTabRegistrySync(store as any, ws) - expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) - expect(ws.sendTabsSyncQuery.mock.calls[0][0].rangeDays).toBe(90) + expect(ws.sendTabsSyncPush.mock.calls[0][0].clientInstanceId).toBe(firstClientId) + expect(ws.sendTabsSyncQuery.mock.calls[0][0].clientInstanceId).toBe(firstClientId) + expect(getCurrentTabRegistryClientInstanceId()).toBe(firstClientId) + stop() + }) + + it('applies tabs.sync.snapshot responses into store dispatch', () => { + const store = createStore() + + const stop = startTabRegistrySync(store as any, ws) + const queryCall = ws.sendTabsSyncQuery.mock.calls[0][0] + const requestId = queryCall.requestId + + wsMessageHandlers.forEach((handler) => handler({ + type: 'tabs.sync.snapshot', + requestId, + data: { + localOpen: [], + remoteOpen: [], + closed: [], + }, + })) + + expect(dispatch.mock.calls.some((call) => call[0]?.type === 'tabRegistry/setTabRegistrySnapshot')).toBe(true) stop() }) - it('re-queries with the current search range after reconnect', () => { + it('ignores stale tabs.sync.snapshot responses for older retention queries', () => { + const mutatingDispatch = vi.fn((action: any) => { + dispatch(action) + if (action?.type === 'tabRegistry/setTabRegistrySnapshot') { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + ...action.payload, + loading: false, + }, + } + } + }) + const stop = startTabRegistrySync(createStore(mutatingDispatch) as any, ws) + const firstRequestId = ws.sendTabsSyncQuery.mock.calls[0][0].requestId state = { ...state, tabRegistry: { ...state.tabRegistry, - searchRangeDays: 365, + closedTabRetentionDays: 7, + searchRangeDays: 7, }, } + listeners.forEach((listener) => listener()) + const secondRequestId = ws.sendTabsSyncQuery.mock.calls[1][0].requestId + wsMessageHandlers.forEach((handler) => handler({ + type: 'tabs.sync.snapshot', + requestId: secondRequestId, + data: { + localOpen: [], + sameDeviceOpen: [], + remoteOpen: [], + closed: [], + devices: [], + }, + })) + wsMessageHandlers.forEach((handler) => handler({ + type: 'tabs.sync.snapshot', + requestId: firstRequestId, + data: { + localOpen: [], + sameDeviceOpen: [], + remoteOpen: [], + closed: [{ tabKey: 'closed-10-days' }], + devices: [], + }, + })) + + expect(state.tabRegistry.closed.map((record: any) => record.tabKey)).toEqual([]) + stop() + }) + + it('keeps the original lease stable and rotates only the duplicated sessionStorage client id', () => { + const store = createStore() + + const stop = startTabRegistrySync(store as any, ws) + const firstClientId = ws.sendTabsSyncPush.mock.calls[0][0].clientInstanceId + expect(broadcastChannels).toHaveLength(1) + const initialClaim = broadcastChannels[0].postMessage.mock.calls[0][0] + + broadcastChannels[0].onmessage?.({ + data: { + type: 'tabs-registry-client-claim', + clientInstanceId: firstClientId, + leaseId: 'other-window', + }, + }) + + expect(ws.sendTabsSyncPush.mock.calls.at(-1)?.[0].clientInstanceId).toBe(firstClientId) + expect(broadcastChannels[0].postMessage.mock.calls.at(-1)?.[0]).toMatchObject({ + type: 'tabs-registry-client-active', + clientInstanceId: firstClientId, + claimantLeaseId: 'other-window', + }) + + broadcastChannels[0].onmessage?.({ + data: { + type: 'tabs-registry-client-active', + clientInstanceId: firstClientId, + leaseId: 'other-window', + claimantLeaseId: initialClaim.leaseId, + }, + }) + vi.advanceTimersByTime(CLIENT_LEASE_GRACE_MS) + + expect(ws.sendTabsSyncPush.mock.calls.at(-1)?.[0].clientInstanceId).not.toBe(firstClientId) + expect(sessionStorage.getItem('freshell.tabs.client-instance-id.v1')).not.toBe(firstClientId) + stop() + }) + + it('does not publish under a copied sessionStorage client id before lease collision resolution', () => { + const copiedClientId = 'client-copied-window' + sessionStorage.setItem('freshell.tabs.client-instance-id.v1', copiedClientId) + sessionStorage.setItem('freshell.tabs.snapshot-revision.v1', '11') + const stop = startTabRegistrySync(createStore() as any, ws) + expect(ws.sendTabsSyncQuery).not.toHaveBeenCalled() + expect(ws.sendTabsSyncPush).not.toHaveBeenCalled() + const initialClaim = broadcastChannels[0].postMessage.mock.calls[0][0] + + broadcastChannels[0].onmessage?.({ + data: { + type: 'tabs-registry-client-active', + clientInstanceId: copiedClientId, + leaseId: 'original-window', + claimantLeaseId: initialClaim.leaseId, + }, + }) + vi.advanceTimersByTime(CLIENT_LEASE_GRACE_MS) + + expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncQuery.mock.calls[0][0].clientInstanceId).not.toBe(copiedClientId) + expect(ws.sendTabsSyncPush.mock.calls[0][0].clientInstanceId).not.toBe(copiedClientId) + stop() + }) + + it('preserves the sessionStorage client id and advances revision across reloads', () => { + const firstStop = startTabRegistrySync(createStore() as any, ws) + const firstPush = ws.sendTabsSyncPush.mock.calls[0][0] + firstStop() + + ws.sendTabsSyncPush.mockClear() + const secondStop = startTabRegistrySync(createStore() as any, ws) + vi.advanceTimersByTime(CLIENT_LEASE_GRACE_MS) + const secondPush = ws.sendTabsSyncPush.mock.calls[0][0] + + expect(secondPush.clientInstanceId).toBe(firstPush.clientInstanceId) + expect(secondPush.snapshotRevision).toBeGreaterThan(firstPush.snapshotRevision) + secondStop() + }) + + it('assigns a distinct client id to another active window without shared sessionStorage', () => { + const firstStop = startTabRegistrySync(createStore() as any, ws) + const firstClientId = ws.sendTabsSyncPush.mock.calls[0][0].clientInstanceId + + sessionStorage.clear() + ws.sendTabsSyncPush.mockClear() + const secondStop = startTabRegistrySync(createStore() as any, ws) + const secondClientId = ws.sendTabsSyncPush.mock.calls[0][0].clientInstanceId + + expect(secondClientId).not.toBe(firstClientId) + secondStop() + firstStop() + }) + + it('does not send stale localClosed records from a previous server instance', () => { + state = { + ...state, + connection: { + ...state.connection, + serverInstanceId: 'srv-new', + }, + tabRegistry: { + ...state.tabRegistry, + localClosed: { + stale: { + tabKey: 'local:stale', + tabId: 'stale', + serverInstanceId: 'srv-old', + deviceId: 'local-device', + deviceLabel: 'local-label', + tabName: 'stale', + status: 'closed', + revision: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + closedAt: Date.now(), + paneCount: 0, + titleSetByUser: false, + panes: [], + }, + }, + }, + } const store = { getState: () => state, dispatch, @@ -178,16 +486,231 @@ describe('tabRegistrySync', () => { } const stop = startTabRegistrySync(store as any, ws) - ws.sendTabsSyncQuery.mockClear() + const records = ws.sendTabsSyncPush.mock.calls[0][0].records + expect(records.some((record: any) => record.tabKey === 'local:stale')).toBe(false) + stop() + }) + it('clears stale localClosed records using the fresh websocket server id during reconnect', () => { + ws.serverInstanceId = 'srv-old' + state = { + ...state, + connection: { + ...state.connection, + serverInstanceId: 'srv-old', + }, + tabRegistry: { + ...state.tabRegistry, + localClosed: { + stale: { + tabKey: 'local:stale', + tabId: 'stale', + serverInstanceId: 'srv-old', + deviceId: 'local-device', + deviceLabel: 'local-label', + tabName: 'stale', + status: 'closed', + revision: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + closedAt: Date.now(), + paneCount: 0, + titleSetByUser: false, + panes: [], + }, + }, + }, + } + const mutatingDispatch = vi.fn((action: any) => { + dispatch(action) + if (action?.type === 'tabRegistry/clearTabRegistryLocalClosed') { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + localClosed: {}, + }, + } + } + }) + const stop = startTabRegistrySync(createStore(mutatingDispatch) as any, ws) + + ws.serverInstanceId = 'srv-new' + ws.sendTabsSyncPush.mockClear() wsReconnectHandlers.forEach((handler) => handler()) - expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) - expect(ws.sendTabsSyncQuery.mock.calls[0][0].rangeDays).toBe(365) + expect(mutatingDispatch.mock.calls.some((call) => call[0]?.type === 'tabRegistry/clearTabRegistryLocalClosed')).toBe(true) + const records = ws.sendTabsSyncPush.mock.calls[0][0].records + expect(records.some((record: any) => record.tabKey === 'local:stale')).toBe(false) + expect(records.every((record: any) => record.serverInstanceId === 'srv-new')).toBe(true) stop() }) - it('applies tabs.sync.snapshot responses into store dispatch', () => { + it('forces heartbeat pushes without changing record updatedAt when the fingerprint is unchanged', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + const initialRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + ws.sendTabsSyncPush.mockClear() + + vi.advanceTimersByTime(HEARTBEAT_INTERVAL_MS) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + const heartbeatRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + expect(heartbeatRecord.updatedAt).toBe(initialRecord.updatedAt) + expect(heartbeatRecord.revision).toBe(initialRecord.revision) + stop() + }) + + it('does not send local closed records older than the selected retention window', () => { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + localClosed: { + old: { + tabKey: 'local:old', + tabId: 'old', + serverInstanceId: 'srv-test', + deviceId: 'local-device', + deviceLabel: 'local-label', + tabName: 'old', + status: 'closed', + revision: 1, + createdAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + updatedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + closedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + paneCount: 0, + titleSetByUser: false, + panes: [], + }, + }, + }, + } + + const stop = startTabRegistrySync(createStore() as any, ws) + const records = ws.sendTabsSyncPush.mock.calls[0][0].records + expect(records.some((record: any) => record.tabKey === 'local:old')).toBe(false) + stop() + }) + + it('sends the closed record rather than duplicate open and closed tab keys during close transitions', () => { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + localClosed: { + closing: { + tabKey: 'local-device:tab-1', + tabId: 'tab-1', + serverInstanceId: 'srv-test', + deviceId: 'local-device', + deviceLabel: 'local-label', + tabName: 'freshell', + status: 'closed', + revision: 1, + createdAt: Date.now() - 1_000, + updatedAt: Date.now(), + closedAt: Date.now(), + paneCount: 1, + titleSetByUser: false, + panes: [], + }, + }, + }, + } + + const stop = startTabRegistrySync(createStore() as any, ws) + const matching = ws.sendTabsSyncPush.mock.calls[0][0].records.filter((record: any) => record.tabKey === 'local-device:tab-1') + expect(matching).toHaveLength(1) + expect(matching[0].status).toBe('closed') + stop() + }) + + it('advances record updatedAt when pane snapshot content changes', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + const initialRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + ws.sendTabsSyncPush.mockClear() + vi.setSystemTime(new Date(1_740_000_010_000)) + state = { + ...state, + panes: { + ...state.panes, + layouts: { + ...state.panes.layouts, + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'browser', + url: 'https://example.test/changed', + devToolsOpen: false, + }, + }, + }, + }, + } as RootState + + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + const changedRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + expect(changedRecord.updatedAt).toBeGreaterThan(initialRecord.updatedAt) + expect(changedRecord.revision).toBeGreaterThan(initialRecord.revision) + expect(changedRecord.panes[0].payload.url).toBe('https://example.test/changed') + stop() + }) + + it('advances record updatedAt for timestamp-only tab activity changes', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + const initialRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + ws.sendTabsSyncPush.mockClear() + vi.setSystemTime(new Date(1_740_000_010_000)) + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ + ...tab, + lastInputAt: 1_740_000_010_000, + })), + }, + } + + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + const changedRecord = ws.sendTabsSyncPush.mock.calls[0][0].records[0] + expect(changedRecord.updatedAt).toBeGreaterThan(initialRecord.updatedAt) + expect(changedRecord.revision).toBeGreaterThan(initialRecord.revision) + expect(changedRecord.panes).toEqual(initialRecord.panes) + stop() + }) + + it('normalizes retained local closed records to the current device metadata after rename', () => { + state = { + ...state, + tabRegistry: { + ...state.tabRegistry, + deviceLabel: 'new-label', + localClosed: { + renamed: { + tabKey: 'local:renamed', + tabId: 'renamed', + serverInstanceId: 'srv-test', + deviceId: 'local-device', + deviceLabel: 'old-label', + tabName: 'renamed', + status: 'closed', + revision: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + closedAt: Date.now(), + paneCount: 0, + titleSetByUser: false, + panes: [], + }, + }, + }, + } const store = { getState: () => state, dispatch, @@ -200,20 +723,153 @@ describe('tabRegistrySync', () => { } const stop = startTabRegistrySync(store as any, ws) - const queryCall = ws.sendTabsSyncQuery.mock.calls[0][0] - const requestId = queryCall.requestId + const closedRecord = ws.sendTabsSyncPush.mock.calls[0][0].records.find((record: any) => record.tabKey === 'local:renamed') + expect(closedRecord).toMatchObject({ + deviceId: 'local-device', + deviceLabel: 'new-label', + }) + stop() + }) - wsMessageHandlers.forEach((handler) => handler({ - type: 'tabs.sync.snapshot', - requestId, - data: { - localOpen: [], - remoteOpen: [], - closed: [], + it('sends unload retire through a keepalive beacon and advances the persisted retire revision', () => { + const store = { + getState: () => state, + dispatch, + subscribe: (listener: Listener) => { + listeners.push(listener) + return () => { + listeners = listeners.filter((item) => item !== listener) + } }, + } + + const stop = startTabRegistrySync(store as any, ws) + const pushedRevision = ws.sendTabsSyncPush.mock.calls[0][0].snapshotRevision + stop() + + expect(ws.sendTabsSyncClientRetire).toHaveBeenCalledWith(expect.objectContaining({ + snapshotRevision: pushedRevision + 1, })) + expect(sessionStorage.getItem('freshell.tabs.snapshot-revision.v1')).toBe(String(pushedRevision + 1)) + expect(navigator.sendBeacon).toHaveBeenCalledWith( + '/api/tabs-sync/client-retire', + expect.any(Blob), + ) + }) - expect(dispatch.mock.calls.some((call) => call[0]?.type === 'tabRegistry/setTabRegistrySnapshot')).toBe(true) + it('sends at most one activity snapshot for repeated terminal input in the same minute bucket', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_010_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].updatedAt).toBe(1_740_000_000_000) + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_050_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + stop() + }) + + it('sends a new activity snapshot when terminal input enters the next minute bucket', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_010_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_060_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(2) + expect(ws.sendTabsSyncPush.mock.calls[1][0].records[0].updatedAt).toBe(1_740_000_060_000) + stop() + }) + + it('still pushes real tab changes immediately even when recency bucket does not change', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ ...tab, title: 'renamed tab' })), + }, + } + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].tabName).toBe('renamed tab') + stop() + }) + + it('does not push when only tab.updatedAt changes', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ ...tab, updatedAt: 1_740_000_999_999 })), + }, + } + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(0) + stop() + }) + + it('preserves a zero recency bucket without falling back to Date.now', () => { + vi.setSystemTime(new Date(1_740_000_010_123)) + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ + ...tab, + createdAt: 0, + updatedAt: 0, + lastInputAt: undefined, + })), + }, + } as RootState + + const stop = startTabRegistrySync(createStore() as any, ws) + + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].updatedAt).toBe(0) stop() }) }) diff --git a/test/unit/client/store/tabsPersistence.test.ts b/test/unit/client/store/tabsPersistence.test.ts index d7df7fc0d..8ace2f860 100644 --- a/test/unit/client/store/tabsPersistence.test.ts +++ b/test/unit/client/store/tabsPersistence.test.ts @@ -15,11 +15,20 @@ const localStorageMock = (() => { Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) import tabsReducer, { updateTab } from '@/store/tabsSlice' +import panesReducer, { replacePane } from '@/store/panesSlice' +import tabRecencyReducer, { + loadPersistedTabRecency, + recordPaneTabActivity, +} from '@/store/tabRecencySlice' +import { tabRecencyPruneMiddleware } from '@/store/tabRecencyPruneMiddleware' import { + PERSIST_DEBOUNCE_MS, persistMiddleware, resetPersistFlushListenersForTests, resetPersistedLayoutCacheForTests, } from '@/store/persistMiddleware' +import { onPersistBroadcast, resetPersistBroadcastForTests } from '@/store/persistBroadcast' +import { LAYOUT_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from '@/store/storage-keys' function makeStore() { return configureStore({ @@ -43,15 +52,65 @@ function makeStore() { }) } +function makeRecencyStore(preloadedState?: any) { + return configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefault) => getDefault().concat( + tabRecencyPruneMiddleware as any, + persistMiddleware as any, + ), + preloadedState: preloadedState ?? { + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'req-tab-1', + title: 'Test', + status: 'running', + mode: 'shell', + createdAt: 123, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: {}, + }, + }, + }) +} + describe('tabs persistence - skipPersist + strip volatile fields', () => { beforeEach(() => { localStorageMock.clear() vi.useFakeTimers() resetPersistFlushListenersForTests() + resetPersistBroadcastForTests() }) afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() }) it('does not schedule a new tabs write when meta.skipPersist is set', () => { @@ -84,6 +143,260 @@ describe('tabs persistence - skipPersist + strip volatile fields', () => { expect(parsed.tabs.tabs[0].lastInputAt).toBeUndefined() }) + it('persists recency-only activity to the sidecar without rewriting layout', () => { + const store = makeRecencyStore() + const broadcasts: Array<{ key: string; raw: string }> = [] + const unsubscribe = onPersistBroadcast((msg) => { + broadcasts.push({ key: msg.key, raw: msg.raw }) + }) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + try { + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + expect(localStorage.getItem(LAYOUT_STORAGE_KEY)).toBeNull() + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(broadcasts.map((msg) => msg.key)).toEqual([TAB_RECENCY_STORAGE_KEY]) + } finally { + unsubscribe() + } + }) + + it('does not rewrite the recency sidecar when topology changes prune nothing', () => { + const store = makeRecencyStore() + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + store.dispatch(replacePane({ tabId: 'tab-1', paneId: 'pane-1' })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(setItemSpy).toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + expect(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)).toBeNull() + }) + + it('prunes pane recency immediately when a terminal pane becomes non-terminal', () => { + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-live': 1_740_000_000_000, + 'pane-stale': 1_740_000_060_000, + }, + })) + + const store = makeRecencyStore({ + tabs: { + tabs: [{ + id: 'tab-live', + createRequestId: 'tab-live', + title: 'Live', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-live', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-live': { + type: 'split', + id: 'root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-live', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-live', status: 'running' }, + }, + { + type: 'leaf', + id: 'pane-stale', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-stale', status: 'running' }, + }, + ], + }, + }, + activePane: { 'tab-live': 'pane-live' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: loadPersistedTabRecency(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)), + }) + + store.dispatch(replacePane({ tabId: 'tab-live', paneId: 'pane-stale' })) + + expect(store.getState().tabRecency.paneLastInputAt).toEqual({ + 'pane-live': 1_740_000_000_000, + }) + + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-live': 1_740_000_000_000, + }, + }) + }) + + it('prunes persisted recency during real store startup before pane ids can be reused', async () => { + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ + version: 4, + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Picker Tab', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + }, + panes: { + version: 7, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-reused', + content: { kind: 'picker' }, + }, + }, + activePane: { 'tab-1': 'pane-reused' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-reused': 1_740_000_060_000, + }, + })) + + vi.resetModules() + const [{ store }, { updatePaneContent }] = await Promise.all([ + import('@/store/store'), + import('@/store/panesSlice'), + ]) + + expect(store.getState().tabRecency.paneLastInputAt).not.toHaveProperty('pane-reused') + + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: {}, + }) + + store.dispatch(updatePaneContent({ + tabId: 'tab-1', + paneId: 'pane-reused', + content: { kind: 'terminal', mode: 'shell' }, + })) + + expect(store.getState().tabRecency.paneLastInputAt).not.toHaveProperty('pane-reused') + }) + + it('does not persist same-bucket no-op tab recency actions', () => { + const store = makeRecencyStore({ + tabs: { + tabs: [], + activeTabId: null, + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }, + }) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_050_000, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + }) + + it('merges recency-only persistence with the existing sidecar by per-pane max', () => { + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-existing': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + })) + const store = makeRecencyStore({ + tabs: { + tabs: [], + activeTabId: null, + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-shared': 1_740_000_000_000, + }, + }, + }) + + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-new', + at: 1_740_000_060_000, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-existing': 1_740_000_120_000, + 'pane-new': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + it('drops stale shared session identity on initial load when the persisted layout is split', async () => { localStorageMock.clear() resetPersistedLayoutCacheForTests() diff --git a/test/unit/client/store/tabsSlice.test.ts b/test/unit/client/store/tabsSlice.test.ts index 24d6d0bf0..af070377e 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -11,7 +11,7 @@ import tabsReducer, { openSessionTab, TabsState, } from '../../../../src/store/tabsSlice' -import panesReducer, { initLayout } from '../../../../src/store/panesSlice' +import panesReducer, { initLayout, splitPane } from '../../../../src/store/panesSlice' import connectionReducer from '../../../../src/store/connectionSlice' import extensionsReducer from '../../../../src/store/extensionsSlice' import type { Tab } from '../../../../src/store/types' @@ -781,7 +781,7 @@ describe('tabsSlice', () => { }) }) - it('repairs a mis-restored single-pane session tab when the reopened session resolves to agent-chat', async () => { + it('repairs a mis-restored single-pane session tab when the reopened session resolves to fresh-agent', async () => { const store = configureStore({ reducer: { tabs: tabsReducer, @@ -818,8 +818,10 @@ describe('tabsSlice', () => { expect(store.getState().panes.layouts['tab-1']).toMatchObject({ type: 'leaf', content: { - kind: 'agent-chat', - provider: 'freshclaude', + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: VALID_CLAUDE_SESSION_ID, sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID, @@ -828,6 +830,176 @@ describe('tabsSlice', () => { }) }) + it('injects sessionRef into a stale single-pane terminal whose only durable locator is tab-level', async () => { + const store = createOpenSessionStore('srv-current') + + store.dispatch(addTab({ + id: 'tab-opencode-old', + mode: 'opencode', + title: 'Old OpenCode', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + })) + store.dispatch(initLayout({ + tabId: 'tab-opencode-old', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'dead-term-1', + serverInstanceId: 'srv-old', + status: 'running', + }, + })) + + await store.dispatch(openSessionTab({ + provider: 'opencode', + sessionId: 'ses_old', + sessionType: 'opencode', + title: 'Old OpenCode', + cwd: '/repo/project', + })) + + expect(store.getState().panes.layouts['tab-opencode-old']).toMatchObject({ + type: 'leaf', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'dead-term-1', + serverInstanceId: 'srv-old', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + }, + }) + expect(store.getState().tabs.tabs).toHaveLength(1) + expect(store.getState().tabs.activeTabId).toBe('tab-opencode-old') + }) + + it('does not overwrite a terminal pane with a different sessionRef', async () => { + const store = createOpenSessionStore('srv-current') + + store.dispatch(addTab({ + id: 'tab-opencode-old', + mode: 'opencode', + title: 'Old OpenCode', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + })) + store.dispatch(initLayout({ + tabId: 'tab-opencode-old', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'term-other', + serverInstanceId: 'srv-old', + sessionRef: { provider: 'opencode', sessionId: 'ses_other' }, + status: 'running', + }, + })) + + await store.dispatch(openSessionTab({ + provider: 'opencode', + sessionId: 'ses_old', + sessionType: 'opencode', + title: 'Old OpenCode', + })) + + const oldLayout = store.getState().panes.layouts['tab-opencode-old'] + expect(oldLayout).toMatchObject({ + type: 'leaf', + content: { + terminalId: 'term-other', + sessionRef: { provider: 'opencode', sessionId: 'ses_other' }, + }, + }) + expect(store.getState().tabs.tabs).toHaveLength(2) + expect(store.getState().tabs.activeTabId).not.toBe('tab-opencode-old') + }) + + it('does not use tab-level sessionRef to repair multi-pane layouts', async () => { + const store = createOpenSessionStore('srv-current') + + store.dispatch(addTab({ + id: 'tab-opencode-old', + mode: 'opencode', + title: 'Old OpenCode', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + })) + store.dispatch(initLayout({ + tabId: 'tab-opencode-old', + paneId: 'pane-left', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'dead-term-1', + serverInstanceId: 'srv-old', + status: 'running', + }, + })) + store.dispatch(splitPane({ + tabId: 'tab-opencode-old', + paneId: 'pane-left', + direction: 'horizontal', + newPaneId: 'pane-right', + newContent: { + kind: 'terminal', + mode: 'shell', + }, + })) + + await store.dispatch(openSessionTab({ + provider: 'opencode', + sessionId: 'ses_old', + sessionType: 'opencode', + title: 'Old OpenCode', + })) + + expect(store.getState().tabs.tabs).toHaveLength(2) + expect(store.getState().tabs.activeTabId).not.toBe('tab-opencode-old') + const oldLayout = store.getState().panes.layouts['tab-opencode-old'] + expect(JSON.stringify(oldLayout)).not.toContain('"sessionId":"ses_old"') + }) + + it('does not inject tab-level sessionRef into a known-current live terminal', async () => { + const store = createOpenSessionStore('srv-current') + + store.dispatch(addTab({ + id: 'tab-opencode-live', + mode: 'opencode', + title: 'Live OpenCode', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + })) + store.dispatch(initLayout({ + tabId: 'tab-opencode-live', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'live-term-1', + serverInstanceId: 'srv-current', + status: 'running', + }, + })) + + await store.dispatch(openSessionTab({ + provider: 'opencode', + sessionId: 'ses_old', + sessionType: 'opencode', + title: 'Old OpenCode', + })) + + expect(store.getState().panes.layouts['tab-opencode-live']).toMatchObject({ + type: 'leaf', + content: { + kind: 'terminal', + mode: 'opencode', + terminalId: 'live-term-1', + serverInstanceId: 'srv-current', + }, + }) + const liveLayout = store.getState().panes.layouts['tab-opencode-live'] + if (liveLayout?.type === 'leaf' && liveLayout.content.kind === 'terminal') { + expect(liveLayout.content.sessionRef).toBeUndefined() + } + expect(store.getState().tabs.tabs).toHaveLength(2) + expect(store.getState().tabs.activeTabId).not.toBe('tab-opencode-live') + }) + it('activates existing tab when terminalId is already attached', async () => { const store = configureStore({ reducer: { @@ -879,6 +1051,189 @@ describe('tabsSlice', () => { } }) + it('opens a non-restorable running Codex terminal without persisting a fake session identity', async () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + }) + const codexDurability = { + schemaVersion: 1 as const, + state: 'durability_unproven_after_completion' as const, + candidate: { + provider: 'codex' as const, + candidateThreadId: 'thread-pre-durable', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'thread_start_response' as const, + capturedAt: 2_000, + }, + turnCompletedAt: 2_500, + lastProofFailure: { + reason: 'missing' as const, + message: 'missing rollout', + checkedAt: 2_600, + }, + } + + await store.dispatch(openSessionTab({ + sessionId: 'thread-pre-durable', + provider: 'codex', + terminalId: 'term-codex-pre-durable', + title: 'Codex CLI', + isRestorable: false, + codexDurability, + })) + + const tabs = store.getState().tabs.tabs + expect(tabs).toHaveLength(1) + expect(tabs[0]).toMatchObject({ + title: 'Codex CLI', + mode: 'codex', + status: 'running', + }) + expect(tabs[0].sessionRef).toBeUndefined() + expect(tabs[0].sessionMetadataByKey).toBeUndefined() + expect(tabs[0].codexDurability).toEqual(codexDurability) + + const layout = store.getState().panes.layouts[tabs[0].id] + expect(layout).toBeDefined() + if (layout?.type === 'leaf' && layout.content.kind === 'terminal') { + expect(layout.content.terminalId).toBe('term-codex-pre-durable') + expect(layout.content.mode).toBe('codex') + expect(layout.content.sessionRef).toBeUndefined() + expect(layout.content.codexDurability).toEqual(codexDurability) + } + }) + + it('opens a non-restorable Codex row as a fresh terminal when no live terminal can be attached', async () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + }) + + await store.dispatch(openSessionTab({ + sessionId: 'thread-pre-durable', + provider: 'codex', + title: 'Codex CLI', + cwd: '/repo', + isRestorable: false, + })) + + const tabs = store.getState().tabs.tabs + expect(tabs).toHaveLength(1) + expect(tabs[0].sessionRef).toBeUndefined() + expect(tabs[0].sessionMetadataByKey).toBeUndefined() + + const layout = store.getState().panes.layouts[tabs[0].id] + expect(layout).toBeDefined() + if (layout?.type === 'leaf' && layout.content.kind === 'terminal') { + expect(layout.content.mode).toBe('codex') + expect(layout.content.initialCwd).toBe('/repo') + expect(layout.content.sessionRef).toBeUndefined() + expect(layout.content.resumeSessionId).toBeUndefined() + } + }) + + it('preserves candidate Codex durability when reopening with no live terminal', async () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + }) + const codexDurability = { + schemaVersion: 1 as const, + state: 'durability_unproven_after_completion' as const, + candidate: { + provider: 'codex' as const, + candidateThreadId: 'thread-pre-durable', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'thread_start_response' as const, + capturedAt: 2_000, + }, + turnCompletedAt: 2_500, + lastProofFailure: { + reason: 'missing' as const, + message: 'missing rollout', + checkedAt: 2_600, + }, + } + + await store.dispatch(openSessionTab({ + sessionId: 'thread-pre-durable', + provider: 'codex', + title: 'Codex CLI', + cwd: '/repo', + isRestorable: false, + codexDurability, + })) + + const tabs = store.getState().tabs.tabs + expect(tabs).toHaveLength(1) + expect(tabs[0].sessionRef).toBeUndefined() + expect(tabs[0].sessionMetadataByKey).toBeUndefined() + expect(tabs[0].codexDurability).toEqual(codexDurability) + + const layout = store.getState().panes.layouts[tabs[0].id] + expect(layout).toBeDefined() + if (layout?.type === 'leaf' && layout.content.kind === 'terminal') { + expect(layout.content.mode).toBe('codex') + expect(layout.content.initialCwd).toBe('/repo') + expect(layout.content.sessionRef).toBeUndefined() + expect(layout.content.resumeSessionId).toBeUndefined() + expect(layout.content.codexDurability).toEqual(codexDurability) + } + }) + + it('reuses an existing candidate-only Codex pane without promoting it to sessionRef', async () => { + const store = createOpenSessionStore() + const codexDurability = { + schemaVersion: 1 as const, + state: 'captured_pre_turn' as const, + candidate: { + provider: 'codex' as const, + candidateThreadId: 'thread-pre-durable', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + source: 'restored_client_state' as const, + capturedAt: 2_000, + }, + } + + store.dispatch(addTab({ id: 'tab-candidate', mode: 'codex', title: 'Codex CLI' })) + store.dispatch(initLayout({ + tabId: 'tab-candidate', + content: { + kind: 'terminal', + mode: 'codex', + status: 'creating', + initialCwd: '/repo', + codexDurability, + }, + })) + store.dispatch(addTab({ id: 'tab-other', mode: 'shell' })) + + await store.dispatch(openSessionTab({ + sessionId: 'thread-pre-durable', + provider: 'codex', + title: 'Codex CLI', + cwd: '/repo', + isRestorable: false, + })) + + const state = store.getState() + expect(state.tabs.tabs).toHaveLength(2) + expect(state.tabs.activeTabId).toBe('tab-candidate') + expect(state.tabs.tabs.find((tab) => tab.id === 'tab-candidate')?.sessionRef).toBeUndefined() + const layout = state.panes.layouts['tab-candidate'] + if (layout?.type === 'leaf' && layout.content.kind === 'terminal') { + expect(layout.content.sessionRef).toBeUndefined() + expect(layout.content.codexDurability).toEqual(codexDurability) + } + }) + it('uses capitalized provider label for codex tab title', async () => { const store = configureStore({ reducer: { diff --git a/test/unit/client/ui-commands.test.ts b/test/unit/client/ui-commands.test.ts index 6fd5cae6c..95ccebd49 100644 --- a/test/unit/client/ui-commands.test.ts +++ b/test/unit/client/ui-commands.test.ts @@ -80,6 +80,24 @@ describe('handleUiCommand', () => { expect(actions[1].type).toBe('panes/swapPanes') }) + it('selects the tab before selecting a pane', () => { + const actions: any[] = [] + const dispatch = (action: any) => { + actions.push(action) + return action + } + + handleUiCommand({ + type: 'ui.command', + command: 'pane.select', + payload: { tabId: 't1', paneId: 'p1' }, + }, dispatch) + + expect(actions.map((a) => a.type)).toEqual(['tabs/setActiveTab', 'panes/setActivePane']) + expect(actions[0].payload).toBe('t1') + expect(actions[1].payload).toEqual({ tabId: 't1', paneId: 'p1' }) + }) + it('handles pane.rename', () => { const actions: any[] = [] const dispatch = (action: any) => { diff --git a/test/unit/lib/terminal-restore.test.ts b/test/unit/lib/terminal-restore.test.ts index a531d7169..eac792622 100644 --- a/test/unit/lib/terminal-restore.test.ts +++ b/test/unit/lib/terminal-restore.test.ts @@ -40,4 +40,33 @@ describe('terminal-restore', () => { expect(consumeTerminalRestoreRequestId(id)).toBe(false) } }) + + it('fresh recovery request ids are one-shot and separate from restore ids', async () => { + const { + addTerminalFreshRecoveryRequestId, + consumeTerminalFreshRecoveryRequest, + consumeTerminalRestoreRequestId, + } = await import('@/lib/terminal-restore') + + addTerminalFreshRecoveryRequestId('fresh-id-1', 'fresh_after_restore_unavailable') + + expect(consumeTerminalRestoreRequestId('fresh-id-1')).toBe(false) + expect(consumeTerminalFreshRecoveryRequest('fresh-id-1')).toBe('fresh_after_restore_unavailable') + expect(consumeTerminalFreshRecoveryRequest('fresh-id-1')).toBeUndefined() + }) + + it('prefers explicit fresh recovery when a request id is mistakenly registered for both paths', async () => { + const { + addTerminalFreshRecoveryRequestId, + addTerminalRestoreRequestId, + consumeTerminalFreshRecoveryRequest, + consumeTerminalRestoreRequestId, + } = await import('@/lib/terminal-restore') + + addTerminalRestoreRequestId('dual-id') + addTerminalFreshRecoveryRequestId('dual-id', 'fresh_after_restore_unavailable') + + expect(consumeTerminalFreshRecoveryRequest('dual-id')).toBe('fresh_after_restore_unavailable') + expect(consumeTerminalRestoreRequestId('dual-id')).toBe(false) + }) }) diff --git a/test/unit/lib/visible-first-audit-gate.test.ts b/test/unit/lib/visible-first-audit-gate.test.ts index 1ef69516a..0a83990b5 100644 --- a/test/unit/lib/visible-first-audit-gate.test.ts +++ b/test/unit/lib/visible-first-audit-gate.test.ts @@ -151,13 +151,13 @@ describe('evaluateVisibleFirstAuditGate', () => { it('fails on a positive mobile_restricted focusedReadyMs delta', () => { const base = createArtifact() const candidate = createArtifact() - setMetric(candidate, 'agent-chat-cold-boot', 'mobile_restricted', 'focusedReadyMs', 151) + setMetric(candidate, 'fresh-agent-cold-boot', 'mobile_restricted', 'focusedReadyMs', 151) expect(evaluateVisibleFirstAuditGate(base, candidate)).toEqual({ ok: false, violations: [ { - scenarioId: 'agent-chat-cold-boot', + scenarioId: 'fresh-agent-cold-boot', profileId: 'mobile_restricted', metric: 'focusedReadyMs', base: 150, @@ -229,7 +229,7 @@ describe('evaluateVisibleFirstAuditGate', () => { it('prints JSON only and exits non-zero on violations', async () => { const base = createArtifact() const candidate = createArtifact() - setMetric(candidate, 'agent-chat-cold-boot', 'mobile_restricted', 'focusedReadyMs', 151) + setMetric(candidate, 'fresh-agent-cold-boot', 'mobile_restricted', 'focusedReadyMs', 151) const { tempDir, basePath, candidatePath } = await writeArtifacts(base, candidate) tempDirs.add(tempDir) @@ -255,7 +255,7 @@ describe('evaluateVisibleFirstAuditGate', () => { ok: false, violations: [ { - scenarioId: 'agent-chat-cold-boot', + scenarioId: 'fresh-agent-cold-boot', profileId: 'mobile_restricted', metric: 'focusedReadyMs', base: 150, diff --git a/test/unit/lib/visible-first-audit-runner.test.ts b/test/unit/lib/visible-first-audit-runner.test.ts index 6a04e3204..3dd7b9351 100644 --- a/test/unit/lib/visible-first-audit-runner.test.ts +++ b/test/unit/lib/visible-first-audit-runner.test.ts @@ -70,7 +70,7 @@ describe('runVisibleFirstAudit', () => { expect(artifact.scenarios.map((scenario) => scenario.id)).toEqual([ 'auth-required-cold-boot', 'terminal-cold-boot', - 'agent-chat-cold-boot', + 'fresh-agent-cold-boot', 'sidebar-search-large-corpus', 'terminal-reconnect-backlog', 'offscreen-tab-selection', diff --git a/test/unit/lib/visible-first-audit-scenarios.test.ts b/test/unit/lib/visible-first-audit-scenarios.test.ts index f55596f26..065f0f459 100644 --- a/test/unit/lib/visible-first-audit-scenarios.test.ts +++ b/test/unit/lib/visible-first-audit-scenarios.test.ts @@ -7,7 +7,7 @@ describe('visible-first audit scenarios', () => { expect(AUDIT_SCENARIOS.map((scenario) => scenario.id)).toEqual([ 'auth-required-cold-boot', 'terminal-cold-boot', - 'agent-chat-cold-boot', + 'fresh-agent-cold-boot', 'sidebar-search-large-corpus', 'terminal-reconnect-backlog', 'offscreen-tab-selection', @@ -22,7 +22,7 @@ describe('visible-first audit scenarios', () => { '/api/bootstrap', '/api/terminals/:terminalId/viewport', ]) - expect(scenarioMap.get('agent-chat-cold-boot')?.allowedApiRouteIdsBeforeReady).toEqual([ + expect(scenarioMap.get('fresh-agent-cold-boot')?.allowedApiRouteIdsBeforeReady).toEqual([ '/api/bootstrap', '/api/agent-sessions/:sessionId/timeline', ]) diff --git a/test/unit/scripts/dev-pr-queue.test.ts b/test/unit/scripts/dev-pr-queue.test.ts new file mode 100644 index 000000000..902e56f5c --- /dev/null +++ b/test/unit/scripts/dev-pr-queue.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest' +import { + assembleDevQueue, + assertAssemblePreconditions, + buildDevQueuePlan, + buildPrMetadataCommand, + executeDevQueuePlan, + loadPrMetadata, + parsePrList, + type DevQueuePr, +} from '../../../scripts/dev-pr-queue.js' + +const pr = (input: Partial<DevQueuePr> & Pick<DevQueuePr, 'number'>): DevQueuePr => ({ + number: input.number, + state: input.state ?? 'OPEN', + isDraft: input.isDraft ?? false, + baseRefName: input.baseRefName ?? 'main', + headRefOid: input.headRefOid ?? `sha-${input.number}`, + mergeStateStatus: input.mergeStateStatus ?? 'CLEAN', + title: input.title ?? `PR ${input.number}`, + labels: input.labels ?? [], +}) + +describe('dev PR queue planner', () => { + it('parses explicit PR numbers', () => { + expect(parsePrList('321,309,319')).toEqual([321, 309, 319]) + }) + + it('rejects empty PR lists', () => { + expect(() => parsePrList('')).toThrow('At least one PR number is required') + }) + + it('rejects malformed PR lists', () => { + expect(() => parsePrList('321,nope')).toThrow('At least one PR number is required') + expect(() => parsePrList('0')).toThrow('At least one PR number is required') + }) + + it('plans origin/main plus PR heads in the requested order', () => { + const plan = buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [321, 309], + prs: [pr({ number: 321 }), pr({ number: 309 })], + }) + + expect(plan.steps.map((step) => step.label)).toEqual([ + 'fetch-origin-main', + 'reset-dev-to-origin-main', + 'fetch-pr-321', + 'merge-pr-321', + 'fetch-pr-309', + 'merge-pr-309', + ]) + }) + + it('uses no-ff merges so PR boundaries remain visible on dev', () => { + const plan = buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [321], + prs: [pr({ number: 321 })], + }) + + expect(plan.steps.find((step) => step.label === 'merge-pr-321')?.command).toEqual([ + 'git', + 'merge', + '--no-ff', + '--no-edit', + 'refs/remotes/pr/321', + ]) + }) + + it('rejects missing metadata for requested PRs', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [321], + prs: [], + })).toThrow('PR #321 was not found') + }) + + it('rejects draft PRs', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [289], + prs: [pr({ number: 289, isDraft: true })], + })).toThrow('PR #289 is draft') + }) + + it('rejects closed PRs', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [310], + prs: [pr({ number: 310, state: 'CLOSED' })], + })).toThrow('PR #310 is CLOSED, expected OPEN') + }) + + it('rejects PRs that do not target main', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [310], + prs: [pr({ number: 310, baseRefName: 'other' })], + })).toThrow('PR #310 targets other, expected main') + }) + + it('rejects do-not-merge labels', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [289], + prs: [pr({ number: 289, labels: [{ name: 'do-not-merge' }] })], + })).toThrow('PR #289 is labeled do-not-merge') + }) + + it('rejects superseded and approval-artifact labels', () => { + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [310], + prs: [pr({ number: 310, labels: [{ name: 'superseded' }] })], + })).toThrow('PR #310 is labeled superseded') + + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [311], + prs: [pr({ number: 311, labels: [{ name: 'approval-artifact-only' }] })], + })).toThrow('PR #311 is labeled approval-artifact-only') + + expect(() => buildDevQueuePlan({ + originMain: 'origin-sha', + requestedPrs: [312], + prs: [pr({ number: 312, labels: [{ name: 'approval-artifact' }] })], + })).toThrow('PR #312 is labeled approval-artifact') + }) + + it('builds gh commands for exact PR metadata', () => { + expect(buildPrMetadataCommand(321)).toEqual([ + 'gh', + [ + 'pr', + 'view', + '321', + '--repo', + 'danshapiro/freshell', + '--json', + 'number,state,isDraft,baseRefName,headRefOid,mergeStateStatus,title,labels', + ], + ]) + }) + + it('loads explicit PR metadata through the injected runner', async () => { + const calls: string[] = [] + const prs = await loadPrMetadata([321, 309], async (command, args) => { + calls.push([command, ...args].join(' ')) + const number = Number(args[2]) + return JSON.stringify(pr({ number })) + }) + + expect(prs.map((item) => item.number)).toEqual([321, 309]) + expect(calls).toEqual([ + 'gh pr view 321 --repo danshapiro/freshell --json number,state,isDraft,baseRefName,headRefOid,mergeStateStatus,title,labels', + 'gh pr view 309 --repo danshapiro/freshell --json number,state,isDraft,baseRefName,headRefOid,mergeStateStatus,title,labels', + ]) + }) + + it('reports invalid gh metadata clearly', async () => { + await expect(loadPrMetadata([321], async () => 'not json')).rejects.toThrow( + 'Failed to parse gh metadata for PR #321', + ) + }) + + it('refuses assemble outside dev', async () => { + await expect(assertAssemblePreconditions({ + getBranch: async () => 'feature/x', + getStatus: async () => '', + })).rejects.toThrow('Refusing to assemble dev from feature/x') + }) + + it('refuses assemble on an unknown branch', async () => { + await expect(assertAssemblePreconditions({ + getBranch: async () => undefined, + getStatus: async () => '', + })).rejects.toThrow('Refusing to assemble dev from an unknown branch') + }) + + it('refuses assemble with a dirty worktree', async () => { + await expect(assertAssemblePreconditions({ + getBranch: async () => 'dev', + getStatus: async () => ' M package.json', + })).rejects.toThrow('Refusing to reset dev with a dirty worktree') + }) + + it('stops on the first failed merge and reports the PR', async () => { + const executed: string[] = [] + await expect(executeDevQueuePlan({ + originMain: 'origin-sha', + steps: [ + { label: 'reset-dev-to-origin-main', command: ['git', 'reset', '--hard', 'origin-sha'] }, + { label: 'merge-pr-321', command: ['git', 'merge', '--no-ff', '--no-edit', 'refs/remotes/pr/321'] }, + { label: 'merge-pr-309', command: ['git', 'merge', '--no-ff', '--no-edit', 'refs/remotes/pr/309'] }, + ], + }, async (_command, args) => { + executed.push(args.join(' ')) + if (args.includes('refs/remotes/pr/321')) throw new Error('merge failed') + return '' + })).rejects.toThrow('PR #321 did not merge cleanly') + + expect(executed).toHaveLength(2) + }) + + it('stops on the first failed cherry-pick and reports the PR', async () => { + await expect(executeDevQueuePlan({ + originMain: 'origin-sha', + steps: [ + { label: 'cherry-pick-pr-321', command: ['git', 'cherry-pick', 'sha-321'] }, + ], + }, async () => { + throw new Error('conflict') + })).rejects.toThrow('PR #321 did not cherry-pick cleanly') + }) + + it('reports non-merge command failures clearly', async () => { + await expect(executeDevQueuePlan({ + originMain: 'origin-sha', + steps: [ + { label: 'fetch-origin-main', command: ['git', 'fetch', 'origin', 'main'] }, + ], + }, async () => { + throw new Error('network unavailable') + })).rejects.toThrow('Step fetch-origin-main failed: network unavailable') + }) + + it('validates metadata before resetting dev', async () => { + const events: string[] = [] + await expect(assembleDevQueue({ + requestedPrs: [289], + run: async (command, args) => { + events.push([command, ...args].join(' ')) + if (args.join(' ') === 'rev-parse origin/main') return 'origin-sha' + if (args.includes('view')) { + return JSON.stringify(pr({ number: 289, labels: [{ name: 'do-not-merge' }] })) + } + return '' + }, + getBranch: async () => 'dev', + getStatus: async () => '', + })).rejects.toThrow('PR #289 is labeled do-not-merge') + + expect(events.some((event) => event.includes('reset --hard'))).toBe(false) + }) + + it('assembles dev after preconditions and metadata validation', async () => { + const events: string[] = [] + await assembleDevQueue({ + requestedPrs: [321], + run: async (command, args) => { + events.push([command, ...args].join(' ')) + if (args.join(' ') === 'rev-parse origin/main') return 'origin-sha' + if (args.includes('view')) return JSON.stringify(pr({ number: 321 })) + return '' + }, + getBranch: async () => 'dev', + getStatus: async () => '', + }) + + expect(events).toEqual([ + 'git fetch origin main', + 'git rev-parse origin/main', + 'gh pr view 321 --repo danshapiro/freshell --json number,state,isDraft,baseRefName,headRefOid,mergeStateStatus,title,labels', + 'git fetch origin main', + 'git reset --hard origin-sha', + 'git fetch origin +refs/pull/321/head:refs/remotes/pr/321', + 'git merge --no-ff --no-edit refs/remotes/pr/321', + ]) + }) +}) diff --git a/test/unit/scripts/selfhost-branch.test.ts b/test/unit/scripts/selfhost-branch.test.ts new file mode 100644 index 000000000..58e9d8511 --- /dev/null +++ b/test/unit/scripts/selfhost-branch.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { validateLaunchBranch } from '../../../scripts/selfhost-branch.js' + +describe('selfhost branch CLI helper', () => { + it('returns success on dev', async () => { + const result = await validateLaunchBranch({ + env: {}, + getBranch: async () => 'dev', + }) + + expect(result).toEqual({ ok: true, branch: 'dev' }) + }) + + it('returns a clear error on main', async () => { + const result = await validateLaunchBranch({ + env: {}, + getBranch: async () => 'main', + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain("Refusing to self-host from local 'main'") + } + }) + + it('rejects main even when FRESHELL_SELFHOST_BRANCH is main', async () => { + const result = await validateLaunchBranch({ + env: { FRESHELL_SELFHOST_BRANCH: 'main' }, + getBranch: async () => 'main', + }) + + expect(result.ok).toBe(false) + }) + + it('surfaces git branch lookup failures', async () => { + const result = await validateLaunchBranch({ + env: {}, + getBranch: vi.fn(async () => undefined), + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('Could not determine the current Git branch') + } + }) +}) diff --git a/test/unit/server/agent-api/layout-store.fresh-agent.test.ts b/test/unit/server/agent-api/layout-store.fresh-agent.test.ts new file mode 100644 index 000000000..474534c51 --- /dev/null +++ b/test/unit/server/agent-api/layout-store.fresh-agent.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { LayoutStore } from '../../../../server/agent-api/layout-store.js' + +describe('LayoutStore fresh-agent titles', () => { + it('derives a fresh-agent pane title from sessionType', () => { + const store = new LayoutStore() + store.updateFromUi({ + tabs: [{ id: 'tab-1', title: 'Fresh Agent' }], + activeTabId: 'tab-1', + activePane: { 'tab-1': 'pane-1' }, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + }, + }, + }, + }, 'conn-1') + + expect(store.listPanes('tab-1')[0]?.title).toBe('Freshcodex') + }) +}) diff --git a/test/unit/server/agent-layout-schema.test.ts b/test/unit/server/agent-layout-schema.test.ts index 467af95d6..128c3c4f8 100644 --- a/test/unit/server/agent-layout-schema.test.ts +++ b/test/unit/server/agent-layout-schema.test.ts @@ -50,4 +50,31 @@ describe('UiLayoutSyncSchema', () => { expect(parsed.success).toBe(false) }) + + it('accepts fresh-agent pane payloads in synchronized layouts', () => { + const parsed = UiLayoutSyncSchema.safeParse({ + type: 'ui.layout.sync', + tabs: [{ id: 'tab_a', title: 'alpha' }], + activeTabId: 'tab_a', + layouts: { + tab_a: { + type: 'leaf', + id: 'pane_a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'idle', + }, + }, + }, + activePane: { tab_a: 'pane_a' }, + paneTitles: {}, + paneTitleSetByUser: {}, + timestamp: Date.now(), + }) + + expect(parsed.success).toBe(true) + }) }) diff --git a/test/unit/server/agent-layout-store-write.test.ts b/test/unit/server/agent-layout-store-write.test.ts index e32b469e0..7049e18eb 100644 --- a/test/unit/server/agent-layout-store-write.test.ts +++ b/test/unit/server/agent-layout-store-write.test.ts @@ -66,6 +66,7 @@ it('lists pane titles from the public pane snapshot', () => { }, activePane: { tab_a: 'pane_1' }, paneTitles: { tab_a: { pane_1: 'Logs' } }, + paneTitleSetByUser: { tab_a: { pane_1: true } }, timestamp: Date.now(), }, 'conn-1') @@ -173,6 +174,7 @@ it('swaps pane titles with pane content so title-based targeting stays aligned', }, activePane: { tab_a: 'pane_1' }, paneTitles: { tab_a: { pane_1: 'Codex', pane_2: 'Editor' } }, + paneTitleSetByUser: { tab_a: { pane_1: true, pane_2: true } }, timestamp: Date.now(), } as any, 'conn-1') diff --git a/test/unit/server/coding-cli/claude-provider.test.ts b/test/unit/server/coding-cli/claude-provider.test.ts index b5a953893..04d343e61 100644 --- a/test/unit/server/coding-cli/claude-provider.test.ts +++ b/test/unit/server/coding-cli/claude-provider.test.ts @@ -16,7 +16,7 @@ import { getClaudeHome } from '../../../../server/claude-home' import { looksLikePath } from '../../../../server/coding-cli/utils' const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' -const SESSION_A = '11111111-1111-1111-1111-111111111111' +const SESSION_A = '11111111-1111-4111-8111-111111111111' const SESSION_B = '22222222-2222-2222-2222-222222222222' const SESSION_C = '33333333-3333-3333-3333-333333333333' const SESSION_D = '44444444-4444-4444-4444-444444444444' diff --git a/test/unit/server/coding-cli/codex-app-server/client.test.ts b/test/unit/server/coding-cli/codex-app-server/client.test.ts index ea8bc325f..d30c34063 100644 --- a/test/unit/server/coding-cli/codex-app-server/client.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -14,11 +14,10 @@ type FakeServerBehavior = { closeSocketAfterMethodsOnce?: string[] delayMethodsMs?: Record<string, number> ignoreMethods?: string[] - loadedThreadIds?: string[] - notificationsAfterMethods?: Record<string, unknown[]> + notifyAfterMethodsOnce?: Record<string, Array<{ method: string; params?: unknown }>> requireJsonRpc?: boolean + rejectJsonRpc?: boolean requireInitializeBeforeOtherMethods?: boolean - requireInitializedNotification?: boolean overrides?: Record<string, { result?: unknown; error?: { code: number; message: string } }> } @@ -98,6 +97,24 @@ async function stopFakeCodexAppServer(handle: FakeServerHandle): Promise<void> { }) } +async function waitFor(assertion: () => void | Promise<void>, timeoutMs = 1_000): Promise<void> { + const deadline = Date.now() + timeoutMs + let lastError: unknown + + while (Date.now() < deadline) { + try { + await assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + + if (lastError instanceof Error) throw lastError + throw new Error('Timed out waiting for assertion') +} + afterEach(async () => { await Promise.all([...fakeServers].map((server) => stopFakeCodexAppServer(server))) }) @@ -120,31 +137,208 @@ describe('CodexAppServerClient', () => { const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) await client.initialize() - await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toEqual({ - threadId: 'thread-new-1', + await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toMatchObject({ + thread: { + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }, }) }) - it('sends JSON-RPC 2.0 envelopes to the app-server', async () => { - const server = await startFakeCodexAppServer({ requireJsonRpc: true }) + it('surfaces thread/started notifications to sidecar consumers', async () => { + const server = await startFakeCodexAppServer() const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + const startedThread = new Promise<{ id: string; path: string | null; ephemeral: boolean }>((resolve) => { + client.onThreadStarted((thread) => resolve(thread)) + }) + await client.initialize() - await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toEqual({ - threadId: 'thread-new-1', + await client.startThread({ cwd: '/repo/worktree' }) + + await expect(startedThread).resolves.toMatchObject({ + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }) + }) + + it('emits thread lifecycle notifications from app-server notifications', async () => { + const server = await startFakeCodexAppServer({ + notifyAfterMethodsOnce: { + initialize: [ + { + method: 'thread/started', + params: { + thread: { + id: 'thread-resume-1', + path: '/tmp/codex/rollout-thread-resume-1.jsonl', + ephemeral: false, + }, + }, + }, + ], + }, + }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + const lifecycle = vi.fn() + client.onThreadLifecycle(lifecycle) + + await client.initialize() + + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith({ + kind: 'thread_started', + thread: expect.objectContaining({ + id: 'thread-resume-1', + path: '/tmp/codex/rollout-thread-resume-1.jsonl', + ephemeral: false, + }), + })) + }) + + it('emits thread closed lifecycle notifications from app-server notifications', async () => { + const server = await startFakeCodexAppServer({ + notifyAfterMethodsOnce: { + initialize: [ + { + method: 'thread/closed', + params: { + threadId: 'thread-resume-1', + }, + }, + ], + }, + }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + const lifecycle = vi.fn() + client.onThreadLifecycle(lifecycle) + + await client.initialize() + + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith({ + kind: 'thread_closed', + threadId: 'thread-resume-1', + })) + }) + + it('emits thread status lifecycle notifications from app-server notifications', async () => { + const server = await startFakeCodexAppServer({ + notifyAfterMethodsOnce: { + initialize: [ + { + method: 'thread/status/changed', + params: { + threadId: 'thread-resume-1', + status: { type: 'notLoaded' }, + }, + }, + ], + }, + }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + const lifecycle = vi.fn() + client.onThreadLifecycle(lifecycle) + + await client.initialize() + + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith({ + kind: 'thread_status_changed', + threadId: 'thread-resume-1', + status: { type: 'notLoaded' }, + })) + }) + + it('emits a disconnect callback when the app-server client socket closes unexpectedly', async () => { + const server = await startFakeCodexAppServer({ closeSocketAfterMethodsOnce: ['initialize'] }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + const onDisconnect = vi.fn() + client.onDisconnect(onDisconnect) + + await client.initialize() + + await waitFor(() => expect(onDisconnect).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'close', + }))) + }) + + it('starts rich Codex threads with raw events enabled when requested', async () => { + const server = await startFakeCodexAppServer({ + overrides: { + 'thread/start': { + result: { + thread: { id: 'thread-rich-1', path: null, ephemeral: false }, + cwd: '/repo/worktree', + model: 'fixture-model', + modelProvider: 'openai', + instructionSources: [], + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandbox: 'danger-full-access', + }, + }, + }, + }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.startThread({ cwd: '/repo/worktree', richClient: true })).resolves.toMatchObject({ + thread: { + id: 'thread-rich-1', + path: null, + ephemeral: false, + }, + }) + }) + + it('sends Codex app-server envelopes without jsonrpc', async () => { + const server = await startFakeCodexAppServer({ rejectJsonRpc: true }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toMatchObject({ + thread: { + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }, }) }) it('sends thread/resume and returns the exact resumed thread id', async () => { - const server = await startFakeCodexAppServer() + const server = await startFakeCodexAppServer({ + overrides: { + 'thread/resume': { + result: { + thread: { + id: '019d9859-5670-72b1-851f-794ad7fef112', + path: '/tmp/rollout-019d9859-5670-72b1-851f-794ad7fef112.jsonl', + ephemeral: false, + }, + cwd: '/repo/worktree', + model: 'fixture-model', + modelProvider: 'openai', + instructionSources: [], + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandbox: { type: 'dangerFullAccess' }, + }, + }, + }, + }) const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) await client.initialize() await expect(client.resumeThread({ threadId: '019d9859-5670-72b1-851f-794ad7fef112', cwd: '/repo/worktree', - })).resolves.toEqual({ - threadId: '019d9859-5670-72b1-851f-794ad7fef112', + })).resolves.toMatchObject({ + thread: { + id: '019d9859-5670-72b1-851f-794ad7fef112', + path: expect.stringMatching(/rollout-019d9859-5670-72b1-851f-794ad7fef112\.jsonl$/), + ephemeral: false, + }, + sandbox: { type: 'dangerFullAccess' }, }) }) @@ -155,8 +349,12 @@ describe('CodexAppServerClient', () => { await client.initialize() await new Promise((resolve) => setTimeout(resolve, 25)) - await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toEqual({ - threadId: 'thread-new-1', + await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toMatchObject({ + thread: { + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }, }) }) @@ -176,75 +374,87 @@ describe('CodexAppServerClient', () => { platformFamily: expect.any(String), platformOs: expect.any(String), }) - await expect(startThreadPromise).resolves.toEqual({ - threadId: 'thread-new-1', + await expect(startThreadPromise).resolves.toMatchObject({ + thread: { + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }, }) }) - it('sends initialized after initialize before later requests', async () => { + it('sends fs/watch and fs/unwatch envelopes and surfaces fs/changed notifications with the original watchId', async () => { + const rolloutPath = '/repo/worktree/.codex/sessions/2026/04/23/rollout-thread-new-1.jsonl' const server = await startFakeCodexAppServer({ - requireInitializeBeforeOtherMethods: true, - requireInitializedNotification: true, + notifyAfterMethodsOnce: { + 'fs/watch': [ + { + method: 'fs/changed', + params: { + watchId: 'watch-rollout', + changedPaths: [rolloutPath], + }, + }, + ], + }, }) const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) - await client.initialize() - - await expect(client.startThread({ cwd: '/repo/worktree' })).resolves.toEqual({ - threadId: 'thread-new-1', - }) - }) - - it('lists loaded in-memory thread ids', async () => { - const server = await startFakeCodexAppServer({ - loadedThreadIds: ['019d9859-5670-72b1-851f-794ad7fef112', 'thread-new-1'], + const changedEvent = new Promise<{ watchId: string; changedPaths: string[] }>((resolve) => { + client.onFsChanged((event) => resolve(event)) }) - const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) await client.initialize() - - await expect(client.listLoadedThreads()).resolves.toEqual([ - '019d9859-5670-72b1-851f-794ad7fef112', - 'thread-new-1', - ]) + await expect(client.watchPath(rolloutPath, 'watch-rollout')).resolves.toEqual({ + path: rolloutPath, + }) + await expect(changedEvent).resolves.toEqual({ + watchId: 'watch-rollout', + changedPaths: [rolloutPath], + }) + await expect(client.unwatchPath('watch-rollout')).resolves.toBeUndefined() }) - it('emits lifecycle-loss events for closed, notLoaded, and systemError notifications', async () => { + it('emits turn started and completed notifications', async () => { const server = await startFakeCodexAppServer({ notificationsAfterMethods: { 'thread/loaded/list': [ { - method: 'thread/closed', - params: { threadId: 'thread-closed' }, + method: 'turn/started', + params: { threadId: 'thread-1', turnId: 'turn-1', extra: true }, }, { - method: 'thread/status/changed', - params: { threadId: 'thread-not-loaded', status: { type: 'notLoaded' } }, - }, - { - method: 'thread/status/changed', - params: { thread: { id: 'thread-system-error', status: { type: 'systemError' } } }, - }, - { - method: 'thread/status/changed', - params: { threadId: 'thread-running', status: { type: 'running' } }, + method: 'turn/completed', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, }, ], }, }) const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) - const events: unknown[] = [] - const unsubscribe = client.onThreadLifecycleLoss((event) => events.push(event)) + const started: unknown[] = [] + const completed: unknown[] = [] + const unsubscribeStarted = client.onTurnStarted((event) => started.push(event)) + const unsubscribeCompleted = client.onTurnCompleted((event) => completed.push(event)) await client.initialize() await client.listLoadedThreads() await new Promise((resolve) => setTimeout(resolve, 25)) - unsubscribe() - - expect(events).toEqual([ - { method: 'thread/closed', threadId: 'thread-closed' }, - { method: 'thread/status/changed', threadId: 'thread-not-loaded', status: 'notLoaded' }, - { method: 'thread/status/changed', threadId: 'thread-system-error', status: 'systemError' }, + unsubscribeStarted() + unsubscribeCompleted() + + expect(started).toEqual([ + { + threadId: 'thread-1', + turnId: 'turn-1', + params: { threadId: 'thread-1', turnId: 'turn-1', extra: true }, + }, + ]) + expect(completed).toEqual([ + { + threadId: 'thread-1', + turnId: 'turn-1', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, + }, ]) }) @@ -271,4 +481,48 @@ describe('CodexAppServerClient', () => { 'Codex app-server returned an invalid thread/start payload.', ) }) + + it('reads thread snapshots from the app-server thread surface', async () => { + const server = await startFakeCodexAppServer() + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.readThread({ threadId: 'thread-new-1', includeTurns: false })).resolves.toMatchObject({ + thread: { + id: 'thread-new-1', + status: { type: 'idle' }, + turns: [], + }, + }) + }) + + it('lists thread turns from the app-server thread surface', async () => { + const server = await startFakeCodexAppServer() + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.listThreadTurns({ + threadId: 'thread-new-1', + })).resolves.toMatchObject({ + revision: 1770000007, + nextCursor: null, + turns: [expect.objectContaining({ id: 'turn-1' })], + bodies: { 'turn-1': expect.objectContaining({ id: 'turn-1' }) }, + }) + }) + + it('reads an individual thread turn from the app-server thread surface', async () => { + const server = await startFakeCodexAppServer() + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.readThreadTurn({ + threadId: 'thread-new-1', + turnId: 'turn-1', + revision: 7, + })).resolves.toMatchObject({ + turnId: 'turn-1', + revision: 7, + }) + }) }) diff --git a/test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts b/test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts new file mode 100644 index 000000000..e5985260c --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts @@ -0,0 +1,81 @@ +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { proofCodexRollout } from '../../../../../server/coding-cli/codex-app-server/durability-proof.js' + +let tempDir: string + +beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-proof-')) +}) + +afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) +}) + +async function writeRollout(name: string, content: string): Promise<string> { + const filePath = path.join(tempDir, name) + await fsp.writeFile(filePath, content, 'utf8') + return filePath +} + +describe('proofCodexRollout', () => { + it('succeeds when the first JSONL record is matching session_meta', async () => { + const filePath = await writeRollout( + 'rollout.jsonl', + '{"type":"session_meta","payload":{"id":"thread-1","timestamp":"2026-05-14T00:00:00Z"}}\n{"type":"event_msg"}\n', + ) + + await expect(proofCodexRollout({ + rolloutPath: filePath, + candidateThreadId: 'thread-1', + })).resolves.toMatchObject({ + ok: true, + rolloutProofId: 'thread-1', + }) + }) + + it.each([ + ['missing', async () => path.join(tempDir, 'missing.jsonl')], + ['not_regular_file', async () => tempDir], + ['empty', async () => writeRollout('empty.jsonl', '')], + ['malformed_json', async () => writeRollout('malformed.jsonl', '{"type":')], + ['wrong_record_type', async () => writeRollout('wrong-type.jsonl', '{"type":"event_msg","payload":{"id":"thread-1"}}\n')], + ['missing_payload_id', async () => writeRollout('missing-id.jsonl', '{"type":"session_meta","payload":{}}\n')], + ['mismatched_thread_id', async () => writeRollout('mismatch.jsonl', '{"type":"session_meta","payload":{"id":"other"}}\n')], + ] as const)('returns %s for invalid proof files', async (reason, makePath) => { + await expect(proofCodexRollout({ + rolloutPath: await makePath(), + candidateThreadId: 'thread-1', + })).resolves.toMatchObject({ + ok: false, + reason, + }) + }) + + it('requires the first record to match instead of scanning later records', async () => { + const filePath = await writeRollout( + 'later-match.jsonl', + '{"type":"event_msg","payload":{"id":"noise"}}\n{"type":"session_meta","payload":{"id":"thread-1"}}\n', + ) + + await expect(proofCodexRollout({ + rolloutPath: filePath, + candidateThreadId: 'thread-1', + })).resolves.toMatchObject({ + ok: false, + reason: 'wrong_record_type', + }) + }) + + it('rejects relative rollout paths', async () => { + await expect(proofCodexRollout({ + rolloutPath: 'relative/rollout.jsonl', + candidateThreadId: 'thread-1', + })).resolves.toMatchObject({ + ok: false, + reason: 'invalid_path', + }) + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/durability-store.test.ts b/test/unit/server/coding-cli/codex-app-server/durability-store.test.ts new file mode 100644 index 000000000..d5e1d2971 --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/durability-store.test.ts @@ -0,0 +1,178 @@ +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + CodexDurabilityRestoreAmbiguousError, + CodexDurabilityStore, +} from '../../../../../server/coding-cli/codex-app-server/durability-store.js' +import type { CodexDurabilityStoreRecord } from '../../../../../shared/codex-durability.js' + +let tempDir: string + +beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-store-')) +}) + +afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) +}) + +function record(overrides: Partial<CodexDurabilityStoreRecord> = {}): CodexDurabilityStoreRecord { + const now = Date.now() + return { + schemaVersion: 1, + terminalId: 'term-1', + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-1', + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-1', + rolloutPath: path.join(tempDir, 'rollout.jsonl'), + source: 'thread_start_response', + capturedAt: now, + }, + updatedAt: now, + ...overrides, + } +} + +async function writeRawRecordFile(terminalId: string, content: string): Promise<void> { + await fsp.writeFile(path.join(tempDir, `${encodeURIComponent(terminalId)}.json`), content) +} + +describe('CodexDurabilityStore', () => { + it('atomically writes and reads a record', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + const written = await store.write(record()) + + await expect(store.read('term-1')).resolves.toEqual(written) + }) + + it('treats a duplicate matching candidate as idempotent', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + const first = record() + await store.write(first) + const second = record({ state: 'turn_in_progress_unproven', updatedAt: first.updatedAt + 1 }) + + await expect(store.write(second)).resolves.toEqual(second) + }) + + it('rejects a mismatched candidate for the same terminal', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + await store.write(record()) + + await expect(store.write(record({ + candidate: { + provider: 'codex', + candidateThreadId: 'thread-2', + rolloutPath: path.join(tempDir, 'other.jsonl'), + source: 'thread_start_response', + capturedAt: Date.now(), + }, + }))).rejects.toThrow(/candidate mismatch/) + }) + + it('returns undefined for older layouts with no durability store record', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + + await expect(store.read('legacy-terminal')).resolves.toBeUndefined() + }) + + it('finds restore records by terminal id', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + const stored = await store.write(record()) + + await expect(store.readForRestoreLocator({ terminalId: 'term-1' })).resolves.toEqual(stored) + }) + + it('finds restore records by exact tab and pane identity', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + const stored = await store.write(record()) + + await expect(store.readForRestoreLocator({ + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-1', + })).resolves.toEqual(stored) + }) + + it('skips bad records during tab and pane restore scans', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + const stored = await store.write(record()) + await writeRawRecordFile('malformed-record', '{not-json') + await writeRawRecordFile('schema-invalid-record', JSON.stringify({ + schemaVersion: 1, + terminalId: 'schema-invalid-record', + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-1', + state: 'not-a-durability-state', + updatedAt: Date.now(), + })) + await fsp.mkdir(path.join(tempDir, `${encodeURIComponent('directory-record')}.json`)) + + await expect(store.readForRestoreLocator({ + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-1', + })).resolves.toEqual(stored) + }) + + it('keeps exact terminal id restore lookups strict for bad records', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + await writeRawRecordFile('malformed-record', '{not-json') + await writeRawRecordFile('schema-invalid-record', JSON.stringify({ + schemaVersion: 1, + terminalId: 'schema-invalid-record', + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-1', + state: 'not-a-durability-state', + updatedAt: Date.now(), + })) + + await expect(store.readForRestoreLocator({ terminalId: 'malformed-record' })).rejects.toThrow(SyntaxError) + await expect(store.readForRestoreLocator({ terminalId: 'schema-invalid-record' })) + .rejects.toThrow(/invalid for terminal schema-invalid-record/) + }) + + it('does not match a wrong pane or server instance', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + await store.write(record()) + + await expect(store.readForRestoreLocator({ + tabId: 'tab-1', + paneId: 'pane-other', + serverInstanceId: 'srv-1', + })).resolves.toBeUndefined() + await expect(store.readForRestoreLocator({ + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-other', + })).resolves.toBeUndefined() + }) + + it('reports ambiguity instead of choosing by time', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + await store.write(record({ terminalId: 'term-1' })) + await store.write(record({ terminalId: 'term-2', updatedAt: Date.now() + 10 })) + await writeRawRecordFile('malformed-record', '{not-json') + + await expect(store.readForRestoreLocator({ + tabId: 'tab-1', + paneId: 'pane-1', + })).rejects.toBeInstanceOf(CodexDurabilityRestoreAmbiguousError) + }) + + it('deletes records idempotently', async () => { + const store = new CodexDurabilityStore({ dir: tempDir }) + await store.write(record()) + + await expect(store.delete('term-1')).resolves.toBeUndefined() + await expect(store.delete('term-1')).resolves.toBeUndefined() + await expect(store.read('term-1')).resolves.toBeUndefined() + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts b/test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts index 8e8061986..2fdf0b05b 100644 --- a/test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts @@ -13,10 +13,13 @@ function deferred<T = void>() { class FakeRuntime { shutdownCalls = 0 + ensureReadyCalls = 0 startThreadCalls = 0 adopted: Array<{ terminalId: string; generation: number }> = [] loadedThreadListCalls = 0 adoptError?: Error + ensureReadyBlocker?: Promise<void> + ensureReadyError?: Error startThreadBlocker?: Promise<void> shutdownBlocker?: Promise<void> shutdownError?: Error @@ -29,6 +32,9 @@ class FakeRuntime { ) {} async ensureReady() { + this.ensureReadyCalls += 1 + await this.ensureReadyBlocker + if (this.ensureReadyError) throw this.ensureReadyError return { wsUrl: this.wsUrl, processPid: 100, @@ -82,8 +88,14 @@ describe('CodexLaunchPlanner', () => { const second = await planner.planCreate({ cwd: '/repo/two' }) expect(runtimes).toHaveLength(2) - expect(first.remote.wsUrl).toBe('ws://127.0.0.1:43001') - expect(second.remote.wsUrl).toBe('ws://127.0.0.1:43002') + expect(first.remote.wsUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) + expect(second.remote.wsUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) + expect(first.remote.wsUrl).not.toBe('ws://127.0.0.1:43001') + expect(second.remote.wsUrl).not.toBe('ws://127.0.0.1:43002') + expect(first.sessionId).toBeUndefined() + expect(second.sessionId).toBeUndefined() + expect(runtimes[0].startThreadCalls).toBe(0) + expect(runtimes[1].startThreadCalls).toBe(0) await first.sidecar.adopt({ terminalId: 'term-one', generation: 1 }) await second.sidecar.shutdown() @@ -91,10 +103,12 @@ describe('CodexLaunchPlanner', () => { expect(runtimes[0].adopted).toEqual([{ terminalId: 'term-one', generation: 1 }]) expect(runtimes[0].shutdownCalls).toBe(0) expect(runtimes[1].shutdownCalls).toBe(1) + await first.sidecar.shutdown() }) it('shuts down the owned sidecar when planning fails before adoption', async () => { - const runtime = new FakeRuntime('ws://127.0.0.1:43010', 'thread-fail', new Error('start failed')) + const runtime = new FakeRuntime('ws://127.0.0.1:43010', 'thread-fail') + runtime.ensureReadyError = new Error('start failed') const planner = new CodexLaunchPlanner(() => runtime as any) await expect(planner.planCreate({ cwd: '/repo/fail' })).rejects.toThrow('start failed') @@ -103,7 +117,8 @@ describe('CodexLaunchPlanner', () => { }) it('marks planning cleanup teardown failures as sidecar teardown failures', async () => { - const runtime = new FakeRuntime('ws://127.0.0.1:43022', 'thread-fail', new Error('start failed')) + const runtime = new FakeRuntime('ws://127.0.0.1:43022', 'thread-fail') + runtime.ensureReadyError = new Error('start failed') runtime.shutdownError = new Error('verified runtime teardown failed') const planner = new CodexLaunchPlanner(() => runtime as any) @@ -178,19 +193,19 @@ describe('CodexLaunchPlanner', () => { await expect(planner.planCreate({ cwd: '/repo/after-shutdown-complete' })).rejects.toThrow(/shutting down/i) }) - it('rejects and cleans up an in-flight launch plan when shutdown starts before thread creation returns', async () => { + it('rejects and cleans up an in-flight launch plan when shutdown starts before readiness returns', async () => { const runtime = new FakeRuntime('ws://127.0.0.1:43018', 'thread-after-shutdown') - const startThreadGate = deferred() - runtime.startThreadBlocker = startThreadGate.promise + const readinessGate = deferred() + runtime.ensureReadyBlocker = readinessGate.promise const planner = new CodexLaunchPlanner(() => runtime as any) const plan = planner.planCreate({ cwd: '/repo/in-flight' }) - await vi.waitFor(() => expect(runtime.startThreadCalls).toBe(1)) + await vi.waitFor(() => expect(runtime.ensureReadyCalls).toBe(1)) const shutdown = planner.shutdown() await vi.waitFor(() => expect(runtime.shutdownCalls).toBe(1)) - startThreadGate.resolve() + readinessGate.resolve() await expect(plan).rejects.toThrow(/shutting down/i) await expect(shutdown).resolves.toBeUndefined() @@ -266,9 +281,10 @@ describe('CodexLaunchPlanner', () => { const second = await planner.planCreate({ cwd: '/repo/two' }) - expect(second.sessionId).toBe('thread-2') + expect(second.sessionId).toBeUndefined() expect(runtimes).toHaveLength(2) expect(runtimes[0].shutdownCalls).toBe(3) + expect(runtimes[1].startThreadCalls).toBe(0) }) it('waits for every planner-owned sidecar shutdown before reporting a teardown failure', async () => { @@ -299,7 +315,7 @@ describe('CodexLaunchPlanner', () => { await expect(shutdown).rejects.toThrow('fast verified runtime teardown failed') }) - it('waits for candidate-local loaded-thread readiness', async () => { + it('does not poll loaded-thread state for resume plans', async () => { const runtime = new FakeRuntime( 'ws://127.0.0.1:43020', 'thread-ready', @@ -310,24 +326,7 @@ describe('CodexLaunchPlanner', () => { const plan = await planner.planCreate({ resumeSessionId: 'thread-ready' }) - await expect(plan.sidecar.waitForLoadedThread('thread-ready', { timeoutMs: 1_000, pollMs: 1 })) - .resolves.toBeUndefined() - expect(runtime.loadedThreadListCalls).toBe(3) - }) - - it('stops loaded-thread readiness polling after sidecar shutdown starts', async () => { - const runtime = new FakeRuntime('ws://127.0.0.1:43021', 'thread-never-loads') - const planner = new CodexLaunchPlanner(() => runtime as any) - - const plan = await planner.planCreate({ resumeSessionId: 'thread-never-loads' }) - const readiness = plan.sidecar.waitForLoadedThread('thread-never-loads', { timeoutMs: 250, pollMs: 20 }) - await vi.waitFor(() => expect(runtime.loadedThreadListCalls).toBeGreaterThan(0)) - - await plan.sidecar.shutdown() - await expect(readiness).rejects.toThrow(/shutting down/i) - - const callsAfterShutdown = runtime.loadedThreadListCalls - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(runtime.loadedThreadListCalls).toBe(callsAfterShutdown) + expect(plan.sessionId).toBe('thread-ready') + expect(runtime.loadedThreadListCalls).toBe(0) }) }) diff --git a/test/unit/server/coding-cli/codex-app-server/launch-retry.test.ts b/test/unit/server/coding-cli/codex-app-server/launch-retry.test.ts new file mode 100644 index 000000000..b42c30f5c --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/launch-retry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' + +import { CodexLaunchConfigError } from '../../../../../server/coding-cli/codex-launch-config.js' +import { planCodexLaunchWithRetry } from '../../../../../server/coding-cli/codex-app-server/launch-retry.js' + +describe('planCodexLaunchWithRetry', () => { + it('retries transient launch-planning failures with linear backoff', async () => { + const plan = { sidecar: { shutdown: vi.fn() } } + const planner = { + planCreate: vi.fn() + .mockRejectedValueOnce(new Error('sidecar not ready')) + .mockRejectedValueOnce(new Error('port not ready')) + .mockResolvedValue(plan), + } + const logger = { warn: vi.fn() } + + await expect(planCodexLaunchWithRetry({ + planner: planner as any, + input: { cwd: '/workspace' } as any, + retryDelayMs: 1, + logger, + })).resolves.toBe(plan) + + expect(planner.planCreate).toHaveBeenCalledTimes(3) + expect(logger.warn).toHaveBeenNthCalledWith(1, expect.objectContaining({ + attempt: 1, + attempts: 5, + delayMs: 1, + cwd: '/workspace', + hasResumeSessionId: false, + }), 'Codex launch planning failed; retrying') + expect(logger.warn).toHaveBeenNthCalledWith(2, expect.objectContaining({ + attempt: 2, + attempts: 5, + delayMs: 2, + }), 'Codex launch planning failed; retrying') + }) + + it('does not retry configuration errors', async () => { + const planner = { + planCreate: vi.fn().mockRejectedValue(new CodexLaunchConfigError('Codex is disabled')), + } + + await expect(planCodexLaunchWithRetry({ + planner: planner as any, + input: { cwd: '/workspace' } as any, + retryDelayMs: 1, + })).rejects.toThrow('Codex is disabled') + + expect(planner.planCreate).toHaveBeenCalledTimes(1) + }) + + it('wraps non-Error failures after attempts are exhausted', async () => { + const planner = { + planCreate: vi.fn().mockRejectedValue('temporary failure'), + } + + await expect(planCodexLaunchWithRetry({ + planner: planner as any, + input: { cwd: '/workspace', resumeSessionId: 'thread-1' } as any, + attempts: 2, + retryDelayMs: 1, + })).rejects.toThrow('temporary failure') + + expect(planner.planCreate).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/legacy-sidecar-dead-code.test.ts b/test/unit/server/coding-cli/codex-app-server/legacy-sidecar-dead-code.test.ts new file mode 100644 index 000000000..c8c642ad6 --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/legacy-sidecar-dead-code.test.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +const repoRoot = path.resolve(__dirname, '../../../../..') + +describe('Codex app-server sidecar production surface', () => { + it('does not keep the legacy polling sidecar modules alongside the launch-planner path', () => { + expect(fs.existsSync(path.join(repoRoot, 'server/coding-cli/codex-app-server/sidecar.ts'))).toBe(false) + expect(fs.existsSync(path.join(repoRoot, 'server/coding-cli/codex-app-server/durable-rollout-tracker.ts'))).toBe(false) + expect(fs.existsSync(path.join(repoRoot, 'test/unit/server/coding-cli/codex-app-server/sidecar.test.ts'))).toBe(false) + expect(fs.existsSync(path.join(repoRoot, 'test/unit/server/coding-cli/codex-app-server/durable-rollout-tracker.test.ts'))).toBe(false) + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts b/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts new file mode 100644 index 000000000..9daeac6be --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts @@ -0,0 +1,339 @@ +import WebSocket, { WebSocketServer } from 'ws' +import { afterEach, describe, expect, it } from 'vitest' +import { allocateLocalhostPort } from '../../../../../server/local-port.js' +import { CodexRemoteProxy } from '../../../../../server/coding-cli/codex-app-server/remote-proxy.js' + +type UpstreamHandle = { + server: WebSocketServer + wsUrl: string + messages: unknown[] + binaryFlags: boolean[] + sockets: Set<WebSocket> +} + +const upstreams = new Set<UpstreamHandle>() +const proxies = new Set<CodexRemoteProxy>() + +afterEach(async () => { + await Promise.all([...proxies].map(async (proxy) => { + proxies.delete(proxy) + await proxy.close() + })) + await Promise.all([...upstreams].map(async (upstream) => { + upstreams.delete(upstream) + for (const socket of upstream.sockets) socket.close() + await new Promise<void>((resolve) => upstream.server.close(() => resolve())) + })) +}) + +async function startUpstream(handler?: (socket: WebSocket, message: any) => void): Promise<UpstreamHandle> { + const endpoint = await allocateLocalhostPort() + const sockets = new Set<WebSocket>() + const messages: unknown[] = [] + const binaryFlags: boolean[] = [] + const server = await new Promise<WebSocketServer>((resolve) => { + const wss = new WebSocketServer({ host: endpoint.hostname, port: endpoint.port }, () => resolve(wss)) + wss.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('message', (raw, isBinary) => { + binaryFlags.push(isBinary) + const message = JSON.parse(raw.toString()) + messages.push(message) + handler?.(socket, message) + }) + }) + }) + const handle = { + server, + wsUrl: `ws://${endpoint.hostname}:${endpoint.port}`, + messages, + binaryFlags, + sockets, + } + upstreams.add(handle) + return handle +} + +async function startProxy(upstreamWsUrl: string, options: { + requestHoldTimeoutMs?: number + candidateCaptureTimeoutMs?: number + requireCandidatePersistence?: boolean +} = {}): Promise<CodexRemoteProxy> { + const proxy = new CodexRemoteProxy({ upstreamWsUrl, ...options }) + await proxy.start() + proxies.add(proxy) + return proxy +} + +async function connect(wsUrl: string): Promise<WebSocket> { + const socket = new WebSocket(wsUrl) + await new Promise<void>((resolve, reject) => { + socket.once('open', () => resolve()) + socket.once('error', reject) + }) + return socket +} + +function nextMessage(socket: WebSocket): Promise<any> { + return new Promise((resolve) => { + socket.once('message', (raw) => resolve(JSON.parse(raw.toString()))) + }) +} + +function nextMessageFrame(socket: WebSocket): Promise<{ message: any; isBinary: boolean }> { + return new Promise((resolve) => { + socket.once('message', (raw, isBinary) => resolve({ + message: JSON.parse(raw.toString()), + isBinary, + })) + }) +} + +function socketClosed(socket: WebSocket): Promise<void> { + return new Promise((resolve) => { + if (socket.readyState === WebSocket.CLOSED) { + resolve() + return + } + socket.once('close', () => resolve()) + }) +} + +function delay(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +describe('CodexRemoteProxy', () => { + it('captures a fresh candidate from the thread/start response and forwards the response', async () => { + const upstream = await startUpstream((socket, message) => { + if (message.method === 'thread/start') { + socket.send(JSON.stringify({ + id: message.id, + result: { + thread: { + id: 'thread-1', + path: '/tmp/codex/rollout.jsonl', + ephemeral: false, + }, + }, + })) + } + }) + const proxy = await startProxy(upstream.wsUrl) + const candidates: unknown[] = [] + proxy.onCandidate((candidate) => { + candidates.push(candidate) + proxy.markCandidatePersisted() + }) + const tui = await connect(proxy.wsUrl) + const responsePromise = nextMessageFrame(tui) + + tui.send(JSON.stringify({ id: 1, method: 'thread/start', params: {} })) + + await expect(responsePromise).resolves.toMatchObject({ + isBinary: false, + message: { + id: 1, + result: { + thread: { + id: 'thread-1', + path: '/tmp/codex/rollout.jsonl', + }, + }, + }, + }) + expect(upstream.binaryFlags).toEqual([false]) + expect(candidates).toEqual([ + { + source: 'thread_start_response', + thread: { + id: 'thread-1', + path: '/tmp/codex/rollout.jsonl', + ephemeral: false, + }, + }, + ]) + }) + + it('captures a candidate from thread/started notification', async () => { + const upstream = await startUpstream((socket, message) => { + if (message.method === 'initialize') { + socket.send(JSON.stringify({ id: message.id, result: {} })) + socket.send(JSON.stringify({ + method: 'thread/started', + params: { + thread: { + id: 'thread-notified', + path: '/tmp/codex/notified.jsonl', + }, + }, + })) + } + }) + const proxy = await startProxy(upstream.wsUrl) + const candidate = new Promise((resolve) => { + proxy.onCandidate((event) => { + proxy.markCandidatePersisted() + resolve(event) + }) + }) + const tui = await connect(proxy.wsUrl) + + tui.send(JSON.stringify({ id: 1, method: 'initialize', params: {} })) + + await expect(candidate).resolves.toEqual({ + source: 'thread_started_notification', + thread: { + id: 'thread-notified', + path: '/tmp/codex/notified.jsonl', + ephemeral: false, + }, + }) + }) + + it('holds turn/start until candidate persistence is marked complete', async () => { + const upstream = await startUpstream((socket, message) => { + if (message.method === 'turn/start') { + socket.send(JSON.stringify({ id: message.id, result: { ok: true } })) + } + }) + const proxy = await startProxy(upstream.wsUrl, { candidateCaptureTimeoutMs: 1_000 }) + const tui = await connect(proxy.wsUrl) + const responsePromise = nextMessage(tui) + + tui.send(JSON.stringify({ id: 7, method: 'turn/start', params: { threadId: 'thread-1' } })) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(upstream.messages).toHaveLength(0) + + proxy.markCandidatePersisted() + + await expect(responsePromise).resolves.toEqual({ id: 7, result: { ok: true } }) + expect(upstream.messages).toEqual([ + { id: 7, method: 'turn/start', params: { threadId: 'thread-1' } }, + ]) + }) + + it('fails held turn/start and closes sockets when candidate persistence times out', async () => { + const upstream = await startUpstream() + const proxy = await startProxy(upstream.wsUrl, { + requestHoldTimeoutMs: 20, + candidateCaptureTimeoutMs: 1_000, + }) + const repairTriggers: unknown[] = [] + proxy.onRepairTrigger((event) => repairTriggers.push(event)) + const tui = await connect(proxy.wsUrl) + const responsePromise = nextMessage(tui) + + tui.send(JSON.stringify({ id: 9, method: 'turn/start', params: { threadId: 'thread-1' } })) + + await expect(responsePromise).resolves.toMatchObject({ + id: 9, + error: { + code: -32000, + message: expect.stringContaining('persist Codex restore identity'), + }, + }) + await socketClosed(tui) + expect(upstream.messages).toHaveLength(0) + expect(repairTriggers).toContainEqual({ kind: 'candidate_capture_timeout' }) + }) + + it('does not hold turn/start or arm candidate-capture timeout when candidate persistence is not required', async () => { + const upstream = await startUpstream((socket, message) => { + if (message.method === 'turn/start') { + socket.send(JSON.stringify({ id: message.id, result: { ok: true } })) + } + }) + const proxy = await startProxy(upstream.wsUrl, { + requestHoldTimeoutMs: 20, + candidateCaptureTimeoutMs: 20, + requireCandidatePersistence: false, + }) + const repairTriggers: unknown[] = [] + proxy.onRepairTrigger((event) => repairTriggers.push(event)) + const tui = await connect(proxy.wsUrl) + const responsePromise = nextMessage(tui) + + tui.send(JSON.stringify({ id: 11, method: 'turn/start', params: { threadId: 'durable-thread-1' } })) + + await expect(responsePromise).resolves.toEqual({ id: 11, result: { ok: true } }) + expect(upstream.messages).toEqual([ + { id: 11, method: 'turn/start', params: { threadId: 'durable-thread-1' } }, + ]) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(tui.readyState).toBe(WebSocket.OPEN) + expect(repairTriggers).toEqual([]) + }) + + it('closes an idle TUI when candidate capture times out before user input', async () => { + const upstream = await startUpstream() + const proxy = await startProxy(upstream.wsUrl, { + candidateCaptureTimeoutMs: 20, + }) + const repairTriggers: unknown[] = [] + proxy.onRepairTrigger((event) => repairTriggers.push(event)) + const tui = await connect(proxy.wsUrl) + + await socketClosed(tui) + expect(upstream.messages).toHaveLength(0) + expect(repairTriggers).toContainEqual({ kind: 'candidate_capture_timeout' }) + }) + + it('times out candidate capture even when the TUI never connects to the proxy', async () => { + const upstream = await startUpstream() + const proxy = await startProxy(upstream.wsUrl, { + candidateCaptureTimeoutMs: 20, + }) + const repairTriggers: unknown[] = [] + proxy.onRepairTrigger((event) => repairTriggers.push(event)) + + await delay(50) + + expect(upstream.messages).toHaveLength(0) + expect(repairTriggers).toContainEqual({ kind: 'candidate_capture_timeout' }) + }) + + it('does not arm the no-client candidate-capture timeout for durable resumes', async () => { + const upstream = await startUpstream() + const proxy = await startProxy(upstream.wsUrl, { + candidateCaptureTimeoutMs: 20, + requireCandidatePersistence: false, + }) + const repairTriggers: unknown[] = [] + proxy.onRepairTrigger((event) => repairTriggers.push(event)) + + await delay(50) + + expect(upstream.messages).toHaveLength(0) + expect(repairTriggers).toEqual([]) + }) + + it('emits turn/completed notifications', async () => { + const upstream = await startUpstream((socket, message) => { + if (message.method === 'initialize') { + socket.send(JSON.stringify({ id: message.id, result: {} })) + socket.send(JSON.stringify({ + method: 'turn/completed', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, + })) + } + }) + const proxy = await startProxy(upstream.wsUrl) + const completed = new Promise((resolve) => { + proxy.onTurnCompleted((event) => { + proxy.markCandidatePersisted() + resolve(event) + }) + }) + const tui = await connect(proxy.wsUrl) + + tui.send(JSON.stringify({ id: 1, method: 'initialize', params: {} })) + + await expect(completed).resolves.toEqual({ + threadId: 'thread-1', + turnId: 'turn-1', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, + }) + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/restore-decision.test.ts b/test/unit/server/coding-cli/codex-app-server/restore-decision.test.ts new file mode 100644 index 000000000..8b5abe3a3 --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/restore-decision.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from 'vitest' +import { CODEX_DURABILITY_SCHEMA_VERSION, type CodexCandidateIdentity, type CodexDurabilityRef } from '../../../../../shared/codex-durability.js' +import { + INVALID_RAW_CODEX_RESUME_MESSAGE, + MISSING_CODEX_SESSION_REF_MESSAGE, + planCodexCreateRestoreDecision, + resolveCodexCreateRestoreDecision, + type CodexLiveRestoreTerminal, +} from '../../../../../server/coding-cli/codex-app-server/restore-decision.js' +import type { CodexRolloutProofResult } from '../../../../../server/coding-cli/codex-app-server/durability-proof.js' + +const candidate: CodexCandidateIdentity = { + provider: 'codex', + candidateThreadId: 'thread-1', + rolloutPath: '/tmp/freshell-codex/rollout.jsonl', + source: 'restored_client_state', + capturedAt: 1, +} + +const durability: CodexDurabilityRef = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durability_unproven_after_completion', + candidate, + turnCompletedAt: 2, +} + +const durableDurability: CodexDurabilityRef = { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + candidate, + durableThreadId: 'thread-durable', + turnCompletedAt: 3, +} + +const proofOk: CodexRolloutProofResult = { + ok: true, + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, + rolloutProofId: candidate.candidateThreadId, +} + +const proofMissing: CodexRolloutProofResult = { + ok: false, + reason: 'missing', + message: 'Codex rollout proof file does not exist.', + candidateThreadId: candidate.candidateThreadId, + rolloutPath: candidate.rolloutPath, +} + +describe('Codex create/restore decision', () => { + it('rejects restore requests that only provide a raw legacy resume id', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: true, + legacyResumeSessionId: 'thread-raw', + })).toEqual({ + kind: 'reject_invalid_raw_codex_resume_request', + code: 'INVALID_MESSAGE', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + }) + + it('rejects non-restore creates that provide a raw legacy Codex resume id', () => { + expect(planCodexCreateRestoreDecision({ + legacyResumeSessionId: 'thread-raw', + })).toEqual({ + kind: 'reject_invalid_raw_codex_resume_request', + code: 'INVALID_MESSAGE', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + }) + + it('rejects restore requests without sessionRef, durable ref, or candidate', () => { + expect(planCodexCreateRestoreDecision({ restoreRequested: true })).toEqual({ + kind: 'reject_missing_codex_session_ref', + code: 'RESTORE_UNAVAILABLE', + message: MISSING_CODEX_SESSION_REF_MESSAGE, + }) + }) + + it('routes canonical sessionRef restores without using candidate proof', async () => { + const proofRollout = vi.fn(async () => proofOk) + + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + legacyResumeSessionId: 'thread-raw', + sessionRef: { provider: 'codex', sessionId: 'thread-durable' }, + codexDurability: durability, + proofRollout, + }) + + expect(decision).toEqual({ + kind: 'durable_session_ref_resume', + sessionRef: { provider: 'codex', sessionId: 'thread-durable' }, + sessionId: 'thread-durable', + }) + expect(proofRollout).not.toHaveBeenCalled() + }) + + it('uses durable Codex durability state as a canonical restore sessionRef', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: 'thread-durable', + }, + })).toEqual({ + kind: 'durable_session_ref_resume', + sessionRef: { provider: 'codex', sessionId: 'thread-durable' }, + sessionId: 'thread-durable', + }) + }) + + it('uses explicit sessionRef before durable Codex durability state', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: true, + sessionRef: { provider: 'codex', sessionId: 'thread-explicit' }, + codexDurability: durableDurability, + })).toEqual({ + kind: 'durable_session_ref_resume', + sessionRef: { provider: 'codex', sessionId: 'thread-explicit' }, + sessionId: 'thread-explicit', + }) + }) + + it('uses durable Codex durability state before candidate proof', async () => { + const proofRollout = vi.fn(async () => proofOk) + + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durableDurability, + proofRollout, + }) + + expect(decision).toEqual({ + kind: 'durable_session_ref_resume', + sessionRef: { provider: 'codex', sessionId: 'thread-durable' }, + sessionId: 'thread-durable', + }) + expect(proofRollout).not.toHaveBeenCalled() + }) + + it('rejects raw legacy resume ids even when durable Codex durability is present without sessionRef', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: true, + legacyResumeSessionId: 'thread-raw', + codexDurability: durableDurability, + })).toEqual({ + kind: 'reject_invalid_raw_codex_resume_request', + code: 'INVALID_MESSAGE', + message: INVALID_RAW_CODEX_RESUME_MESSAGE, + }) + }) + + it('plans candidate proof before a restored candidate can become durable', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durability, + })).toEqual({ + kind: 'proof_existing_candidate_first', + candidate, + }) + }) + + it('ignores captured Codex candidates for non-restore fresh creates', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: false, + codexDurability: durability, + })).toEqual({ + kind: 'fresh_codex_launch', + }) + }) + + it('ignores durable Codex durability state for non-restore fresh creates', () => { + expect(planCodexCreateRestoreDecision({ + restoreRequested: false, + codexDurability: durableDurability, + })).toEqual({ + kind: 'fresh_codex_launch', + }) + }) + + it('uses exact rollout proof as the durable session id and returns a matching live terminal when present', async () => { + const liveTerminal: CodexLiveRestoreTerminal = { + terminalId: 'term-live', + createdAt: 10, + codexDurability: durability, + } + + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durability, + proofRollout: async () => proofOk, + findLiveTerminalByCandidate: () => liveTerminal, + }) + + expect(decision).toEqual({ + kind: 'proof_succeeded_resume_durable', + candidate, + proof: proofOk, + sessionId: 'thread-1', + liveTerminal, + }) + }) + + it('attaches the exact live candidate when proof fails but the terminal still exists', async () => { + const liveTerminal: CodexLiveRestoreTerminal = { + terminalId: 'term-unproved-live', + createdAt: 10, + codexDurability: durability, + } + + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durability, + proofRollout: async () => proofMissing, + findLiveTerminalByCandidate: () => liveTerminal, + }) + + expect(decision).toEqual({ + kind: 'proof_failed_attach_live_candidate', + candidate, + proof: proofMissing, + liveTerminal, + }) + }) + + it('fresh-creates with a restore-failed marker when candidate proof fails and no exact live terminal exists', async () => { + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durability, + proofRollout: async () => proofMissing, + findLiveTerminalByCandidate: () => undefined, + }) + + expect(decision).toEqual({ + kind: 'proof_failed_fresh_create', + candidate, + proof: proofMissing, + clearCodexDurability: true, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }) + }) + + it('does not accept a loose live terminal candidate returned by the caller', async () => { + const looseLiveTerminal: CodexLiveRestoreTerminal = { + terminalId: 'term-loose-live', + createdAt: 10, + codexDurability: { + ...durability, + candidate: { + ...candidate, + candidateThreadId: 'thread-other', + }, + }, + } + + const decision = await resolveCodexCreateRestoreDecision({ + restoreRequested: true, + codexDurability: durability, + proofRollout: async () => proofMissing, + findLiveTerminalByCandidate: () => looseLiveTerminal, + }) + + expect(decision).toEqual({ + kind: 'proof_failed_fresh_create', + candidate, + proof: proofMissing, + clearCodexDurability: true, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }) + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/runtime.test.ts b/test/unit/server/coding-cli/codex-app-server/runtime.test.ts index b2108bd4f..0986bbffe 100644 --- a/test/unit/server/coding-cli/codex-app-server/runtime.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/runtime.test.ts @@ -204,6 +204,28 @@ describe('CodexAppServerRuntime', () => { expect(runtime.status()).toBe('running') }) + it('disables Codex apps while starting Freshell-managed app-server processes', async () => { + const tempDir = await makeTempDir() + const argLogPath = path.join(tempDir, 'argv.json') + const runtime = createRuntime({ + env: { + FAKE_CODEX_APP_SERVER_ARG_LOG: argLogPath, + }, + }) + + await runtime.ensureReady() + + const payload = JSON.parse(await fsp.readFile(argLogPath, 'utf8')) as { + argv: string[] + } + const args = payload.argv + + expect(args).toContain('-c') + expect(args).toContain('features.apps=false') + expect(args.indexOf('features.apps=false')).toBeLessThan(args.indexOf('app-server')) + expect(args).toContain('--listen') + }) + it('rejects before spawning on platforms without Linux /proc ownership support', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') if (!originalPlatform?.configurable) { @@ -363,6 +385,44 @@ describe('CodexAppServerRuntime', () => { } }) + it('keeps the same sidecar when wrapper identity is transiently incomplete', async () => { + const tempDir = await makeTempDir() + const metadataDir = path.join(tempDir, 'metadata') + const processGroups: number[] = [] + const seenProcessGroups = new Set<number>() + let identityReadAttempts = 0 + const runtime = createRuntime({ + metadataDir, + serverInstanceId: 'srv-runtime-test', + startupAttemptLimit: 1, + startupAttemptTimeoutMs: 500, + processIdentityReader: async (pid) => { + identityReadAttempts += 1 + if (identityReadAttempts === 1) { + return { commandLine: [], cwd: null, startTimeTicks: null } + } + return readWrapperIdentityForTest(pid) + }, + metadataWriter: async (filePath, metadata) => { + if (!seenProcessGroups.has(metadata.processGroupId)) { + seenProcessGroups.add(metadata.processGroupId) + processGroups.push(metadata.processGroupId) + } + await fsp.mkdir(path.dirname(filePath), { recursive: true }) + await fsp.writeFile(filePath, JSON.stringify(metadata), 'utf8') + }, + }) + + const ready = await runtime.ensureReady() + const record = JSON.parse(await fsp.readFile(ready.metadataPath, 'utf8')) + + expect(processGroups).toEqual([ready.processGroupId]) + expect(identityReadAttempts).toBe(2) + expect(record.wrapperIdentity.commandLine.length).toBeGreaterThan(0) + expect(record.wrapperIdentity.cwd).toEqual(expect.any(String)) + expect(record.wrapperIdentity.startTimeTicks).toEqual(expect.any(Number)) + }, 3_000) + it('tears down both the wrapper and native child in its process group', async () => { const metadataDir = await makeTempDir() const nativePidFile = path.join(metadataDir, 'native.pid') @@ -494,7 +554,7 @@ describe('CodexAppServerRuntime', () => { requestTimeoutMs: 1_000, processIdentityReader: async (pid) => { identityReadAttempts += 1 - if (identityReadAttempts === 1) return null + if (pid === processGroups[0]) return null return readWrapperIdentityForTest(pid) }, metadataWriter: async (filePath, metadata) => { @@ -514,7 +574,7 @@ describe('CodexAppServerRuntime', () => { const record = JSON.parse(await fsp.readFile(ready.metadataPath, 'utf8')) expect(processGroups).toHaveLength(2) - expect(identityReadAttempts).toBe(2) + expect(identityReadAttempts).toBeGreaterThan(2) expect(previousAttemptGoneBeforeRetry).toBe(true) expect(record.processGroupId).toBe(processGroups[1]) expect(record.wrapperIdentity.startTimeTicks).toEqual(expect.any(Number)) @@ -535,7 +595,7 @@ describe('CodexAppServerRuntime', () => { requestTimeoutMs: 1_000, processIdentityReader: async (pid) => { identityReadAttempts += 1 - if (identityReadAttempts === 1) { + if (pid === processGroups[0]) { return { commandLine: [], cwd: null, startTimeTicks: null } } return readWrapperIdentityForTest(pid) @@ -557,7 +617,7 @@ describe('CodexAppServerRuntime', () => { const record = JSON.parse(await fsp.readFile(ready.metadataPath, 'utf8')) expect(processGroups).toHaveLength(2) - expect(identityReadAttempts).toBe(2) + expect(identityReadAttempts).toBeGreaterThan(2) expect(previousAttemptGoneBeforeRetry).toBe(true) expect(record.processGroupId).toBe(processGroups[1]) expect(record.wrapperIdentity.commandLine.length).toBeGreaterThan(0) @@ -921,7 +981,46 @@ describe('CodexAppServerRuntime', () => { ignoredLegacyRecords: [], skippedActiveOwnershipIds: [], failedOwnershipIds: ['ownership-alpha', 'ownership-beta'], - })).toThrow(/startup reaper failed.*ownership-alpha.*ownership-beta/i) + })).toThrow(/startup reaper blocked startup.*failed to reap 2 ownership record.*ownership-alpha.*ownership-beta/i) + }) + + it('reports active live sidecar owners separately from failed cleanup', () => { + let thrown: Error | undefined + + try { + assertCodexStartupReaperSucceeded({ + reapedOwnershipIds: [], + ignoredLegacyRecords: [], + skippedActiveOwnershipIds: ['active-owner'], + failedOwnershipIds: [], + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown).toBeDefined() + expect(thrown?.message).toContain('still owned by a live Freshell server/process') + expect(thrown?.message).toContain('active-owner') + expect(thrown?.message).not.toContain('failed to reap 1 ownership record(s): active-owner') + }) + + it('reports mixed active owners and failed reaps without conflating them', () => { + let thrown: Error | undefined + + try { + assertCodexStartupReaperSucceeded({ + reapedOwnershipIds: [], + ignoredLegacyRecords: [], + skippedActiveOwnershipIds: ['active-owner'], + failedOwnershipIds: ['failed-owner'], + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown).toBeDefined() + expect(thrown?.message).toContain('failed to reap 1 ownership record(s): failed-owner') + expect(thrown?.message).toContain('still owned by a live Freshell server/process: active-owner') }) it('blocks startup when a new-schema ownership record is skipped because the owner pid is live', async () => { @@ -1215,6 +1314,51 @@ describe('CodexAppServerRuntime', () => { }) }) + it('re-emits turn notifications from the sidecar client', async () => { + const runtime = createRuntime({ + env: { + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ + notificationsAfterMethods: { + 'thread/loaded/list': [ + { + method: 'turn/started', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + { + method: 'turn/completed', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, + }, + ], + }, + }), + }, + }) + const started: unknown[] = [] + const completed: unknown[] = [] + const unsubscribeStarted = runtime.onTurnStarted((event) => started.push(event)) + const unsubscribeCompleted = runtime.onTurnCompleted((event) => completed.push(event)) + + await runtime.listLoadedThreads() + await new Promise((resolve) => setTimeout(resolve, 25)) + unsubscribeStarted() + unsubscribeCompleted() + + expect(started).toEqual([ + { + threadId: 'thread-1', + turnId: 'turn-1', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + ]) + expect(completed).toEqual([ + { + threadId: 'thread-1', + turnId: 'turn-1', + params: { threadId: 'thread-1', turnId: 'turn-1', status: 'completed' }, + }, + ]) + }) + it('drops cached state after an unexpected child exit and starts a fresh process on the next call', async () => { const runtime = createRuntime() diff --git a/test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts b/test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts new file mode 100644 index 000000000..c71a740a7 --- /dev/null +++ b/test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { + CODEX_CLIENT_REQUEST_METHODS, + CODEX_RUNTIME_LEAF_VALUES, + CODEX_SERVER_NOTIFICATION_METHODS, + CODEX_SERVER_REQUEST_METHODS, + CODEX_THREAD_ITEM_VARIANTS, +} from '../../../../fixtures/coding-cli/codex-app-server/schema-inventory.js' +import { + CODEX_CLIENT_REQUEST_TRACEABILITY, + CODEX_RUNTIME_LEAF_TRACEABILITY, + CODEX_SERVER_NOTIFICATION_TRACEABILITY, + CODEX_SERVER_REQUEST_TRACEABILITY, + CODEX_THREAD_ITEM_TRACEABILITY, +} from '../../../../fixtures/coding-cli/codex-app-server/schema-traceability.js' + +function expectExactCoverage(label: string, inventory: readonly string[], traced: readonly { name: string }[]) { + expect(traced.map((entry) => entry.name).sort(), label).toEqual([...inventory].sort()) +} + +function expectFilledEntries(label: string, entries: readonly Array<Record<string, unknown>>) { + for (const entry of entries) { + for (const field of ['status', 'owner', 'parser', 'normalizer', 'ui', 'test']) { + expect(entry[field], `${label}.${String(entry.name)}.${field}`).toBeTruthy() + } + } +} + +describe('Codex generated schema traceability', () => { + it('classifies every generated client request method', () => { + expectExactCoverage('client request methods', CODEX_CLIENT_REQUEST_METHODS, CODEX_CLIENT_REQUEST_TRACEABILITY) + expectFilledEntries('client request methods', CODEX_CLIENT_REQUEST_TRACEABILITY) + }) + + it('classifies every generated server request method', () => { + expectExactCoverage('server request methods', CODEX_SERVER_REQUEST_METHODS, CODEX_SERVER_REQUEST_TRACEABILITY) + expectFilledEntries('server request methods', CODEX_SERVER_REQUEST_TRACEABILITY) + }) + + it('classifies every generated server notification method', () => { + expectExactCoverage('server notification methods', CODEX_SERVER_NOTIFICATION_METHODS, CODEX_SERVER_NOTIFICATION_TRACEABILITY) + expectFilledEntries('server notification methods', CODEX_SERVER_NOTIFICATION_TRACEABILITY) + }) + + it('classifies every generated thread item variant', () => { + expectExactCoverage('thread item variants', CODEX_THREAD_ITEM_VARIANTS, CODEX_THREAD_ITEM_TRACEABILITY) + expectFilledEntries('thread item variants', CODEX_THREAD_ITEM_TRACEABILITY) + }) + + it('classifies every runtime leaf type and keeps values explicit', () => { + expectExactCoverage( + 'runtime leaf types', + Object.keys(CODEX_RUNTIME_LEAF_VALUES), + CODEX_RUNTIME_LEAF_TRACEABILITY, + ) + expectFilledEntries('runtime leaf types', CODEX_RUNTIME_LEAF_TRACEABILITY) + + expect(CODEX_RUNTIME_LEAF_VALUES.reasoningEffort).toContain('xhigh') + expect(CODEX_RUNTIME_LEAF_VALUES.reasoningEffort).not.toContain('max') + expect(CODEX_RUNTIME_LEAF_VALUES.askForApproval).not.toContain('bypassPermissions') + }) + + it('records that codex-cli 0.129.0 has no generated thread/turns/list method', () => { + expect(CODEX_CLIENT_REQUEST_METHODS).not.toContain('thread/turns/list') + }) +}) diff --git a/test/unit/server/coding-cli/opencode-activity-tracker.test.ts b/test/unit/server/coding-cli/opencode-activity-tracker.test.ts index 79ff176f9..5c7442f46 100644 --- a/test/unit/server/coding-cli/opencode-activity-tracker.test.ts +++ b/test/unit/server/coding-cli/opencode-activity-tracker.test.ts @@ -93,6 +93,185 @@ describe('OpencodeActivityTracker', () => { tracker.dispose() }) + it('opens SSE before snapshot and emits completion only after association is confirmed', async () => { + vi.useFakeTimers() + const requestOrder: string[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + requestOrder.push('/global/health') + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/event')) { + requestOrder.push('/event') + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.status', + properties: { + sessionID: 'session-oc', + status: { type: 'busy' }, + }, + }, + { + type: 'session.idle', + properties: { + sessionID: 'session-oc', + }, + }, + ]) + } + if (url.endsWith('/session/status')) { + requestOrder.push('/session/status') + return createJsonResponse({}) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0 }) + const completions: unknown[] = [] + tracker.on('association.requested', (payload) => { + expect(completions).toEqual([]) + tracker.confirmSessionAssociation(payload) + }) + tracker.on('turn.complete', (payload) => completions.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(requestOrder.slice(0, 3)).toEqual(['/global/health', '/event', '/session/status']) + expect(completions).toEqual([{ + terminalId: 'term-oc', + sessionId: 'session-oc', + at: expect.any(Number), + }]) + + tracker.dispose() + }) + + it('emits completion when the initial snapshot observes busy before a same-stream idle event', async () => { + vi.useFakeTimers() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.idle', + properties: { + sessionID: 'session-oc', + }, + }, + ]) + } + if (url.endsWith('/session/status')) { + return createJsonResponse({ + 'session-oc': { type: 'busy' }, + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0 }) + const completions: unknown[] = [] + tracker.on('association.requested', (payload) => { + expect(completions).toEqual([]) + tracker.confirmSessionAssociation(payload) + }) + tracker.on('turn.complete', (payload) => completions.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(completions).toEqual([{ + terminalId: 'term-oc', + sessionId: 'session-oc', + at: expect.any(Number), + }]) + expect(tracker.list()).toEqual([]) + + tracker.dispose() + }) + + it('clears ambiguous busy state when every ambiguous session idles on the same SSE stream', async () => { + vi.useFakeTimers() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.status', + properties: { + sessionID: 'session-a', + status: { type: 'busy' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'session-b', + status: { type: 'busy' }, + }, + }, + { + type: 'session.idle', + properties: { + sessionID: 'session-a', + }, + }, + { + type: 'session.idle', + properties: { + sessionID: 'session-b', + }, + }, + ]) + } + if (url.endsWith('/session/status')) { + return createJsonResponse({}) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const log = { warn: vi.fn() } + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + log, + random: () => 0, + }) + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + const completions: unknown[] = [] + tracker.on('changed', (payload) => changes.push(payload)) + tracker.on('association.requested', (payload) => tracker.confirmSessionAssociation(payload)) + tracker.on('turn.complete', (payload) => completions.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(log.warn).toHaveBeenCalledWith( + { + terminalId: 'term-oc', + sessionIds: ['session-a', 'session-b'], + }, + 'OpenCode endpoint reported ambiguous session ownership; suppressing durable adoption.', + ) + expect(changes).toContainEqual({ + upsert: [], + remove: ['term-oc'], + }) + expect(completions).toEqual([]) + expect(tracker.list()).toEqual([]) + + tracker.dispose() + }) + it('keeps health polling on connection errors until the endpoint comes up', async () => { vi.useFakeTimers() let healthCalls = 0 @@ -148,6 +327,13 @@ describe('OpencodeActivityTracker', () => { if (url.endsWith('/event')) { return createSseResponse([ { type: 'server.connected', properties: {} }, + { + type: 'session.status', + properties: { + sessionID: 'session-oc', + status: { type: 'busy' }, + }, + }, { type: 'session.status', properties: { @@ -248,7 +434,7 @@ describe('OpencodeActivityTracker', () => { throw new Error(`Unexpected URL: ${url}`) }) - const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0 }) + const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0, homeDir: '/tmp/nonexistent' }) const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] tracker.on('changed', (payload) => changes.push(payload)) @@ -284,7 +470,15 @@ describe('OpencodeActivityTracker', () => { } if (url.endsWith('/event')) { return createRawSseResponse([ + `data: ${JSON.stringify({ type: 'server.connected', properties: {} })}\n\n`, 'data: {not valid json}\n\n', + `data: ${JSON.stringify({ + type: 'session.status', + properties: { + sessionID: 'session-oc', + status: { type: 'busy' }, + }, + })}\n\n`, `data: ${JSON.stringify({ type: 'session.idle', properties: { sessionID: 'session-oc' } })}\n\n`, ]) } @@ -340,6 +534,13 @@ describe('OpencodeActivityTracker', () => { return createRawSseResponse([ `data: ${JSON.stringify({ type: 'server.connected', properties: {} })}\n\n`, `data: ${JSON.stringify({ type: 'session.progress', properties: { percent: 50 } })}\n\n`, + `data: ${JSON.stringify({ + type: 'session.status', + properties: { + sessionID: 'session-oc', + status: { type: 'busy' }, + }, + })}\n\n`, `data: ${JSON.stringify({ type: 'session.idle', properties: { sessionID: 'session-oc' } })}\n\n`, ]) } @@ -410,4 +611,469 @@ describe('OpencodeActivityTracker', () => { expect(fetchImpl).toHaveBeenCalledTimes(fetchCallsBeforeStop) tracker.dispose() }) + + it('maps child activity to its OpenCode root before classification', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async (sessionIds: readonly string[]) => ({ + rootsBySessionId: new Map([ + ['child_session', 'root_session'], + ]), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/event')) return createSseResponse([{ type: 'server.connected', properties: {} }]) + if (url.endsWith('/session/status')) { + return createJsonResponse({ + child_session: { type: 'busy' }, + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + resolveOpencodeSessionRoots, + }) + + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + tracker.on('changed', (payload) => changes.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-opencode-1', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(changes).toContainEqual({ + upsert: [{ + terminalId: 'term-opencode-1', + sessionId: 'root_session', + phase: 'busy', + updatedAt: expect.any(Number), + }], + remove: [], + }) + expect(resolveOpencodeSessionRoots).toHaveBeenCalledTimes(1) + expect(resolveOpencodeSessionRoots).toHaveBeenCalledWith(['child_session']) + + tracker.dispose() + }) + + it('does not let later child SSE status overwrite a snapshot-resolved root binding', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async (sessionIds: readonly string[]) => ({ + rootsBySessionId: new Map(sessionIds.map((sessionId) => [sessionId, 'root_session'])), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/session/status')) { + return createJsonResponse({ + child_session: { type: 'busy' }, + }) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.status', + properties: { + sessionID: 'child_session', + status: { type: 'busy' }, + }, + }, + ]) + } + throw new Error(`Unexpected URL: ${url}`) + }) + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + resolveOpencodeSessionRoots, + }) + + tracker.trackTerminal({ terminalId: 'term-opencode-1', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(resolveOpencodeSessionRoots).toHaveBeenCalledTimes(1) + expect(tracker.list()).toEqual([ + expect.objectContaining({ + terminalId: 'term-opencode-1', + sessionId: 'root_session', + phase: 'busy', + }), + ]) + + tracker.dispose() + }) + + it('does not choose an arbitrary durable session when multiple root sessions are active', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async () => ({ + rootsBySessionId: new Map([ + ['child-a', 'root_a'], + ['child-b', 'root_b'], + ]), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/session/status')) { + return createJsonResponse({ + 'child-a': { type: 'busy' }, + 'child-b': { type: 'busy' }, + }) + } + if (url.endsWith('/event')) return createSseResponse([{ type: 'server.connected', properties: {} }]) + throw new Error(`Unexpected URL: ${url}`) + }) + const log = { warn: vi.fn() } + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + resolveOpencodeSessionRoots, + log, + }) + + tracker.trackTerminal({ terminalId: 'term-opencode-1', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(tracker.list()).toEqual([ + expect.objectContaining({ + terminalId: 'term-opencode-1', + phase: 'busy', + }), + ]) + expect(tracker.list()[0]).not.toHaveProperty('sessionId') + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-opencode-1', + rootSessionIds: ['root_a', 'root_b'], + unresolvedSessionIds: [], + }, 'OpenCode reported multiple active root sessions; leaving terminal activity unbound.') + + tracker.dispose() + }) + + it('does not resolve OpenCode roots while waiting for health', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async () => ({ + rootsBySessionId: new Map<string, string>(), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return new Response('not ready', { status: 503 }) + throw new Error(`Unexpected URL: ${url}`) + }) + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + resolveOpencodeSessionRoots, + }) + + tracker.trackTerminal({ terminalId: 'term-opencode-1', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(OPENCODE_HEALTH_POLL_MS * 3) + + expect(resolveOpencodeSessionRoots).not.toHaveBeenCalled() + tracker.dispose() + }) + + it('uses session.created topology to suppress child SSE without SQLite lookup', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async () => ({ + rootsBySessionId: new Map<string, string>(), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/session/status')) return createJsonResponse({}) + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.created', + properties: { + sessionID: 'child-1', info: { id: 'child-1', parentID: 'parent-1' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'child-1', + status: { type: 'busy' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'parent-1', + status: { type: 'busy' }, + }, + }, + { + type: 'session.idle', + properties: { + sessionID: 'parent-1', + }, + }, + ]) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + random: () => 0, + resolveOpencodeSessionRoots, + }) + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + tracker.on('changed', (payload) => changes.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + const upserts = changes.filter(c => c.upsert.length > 0) + expect(upserts).toContainEqual({ + upsert: [{ + terminalId: 'term-oc', + sessionId: 'parent-1', + phase: 'busy', + updatedAt: expect.any(Number), + }], + remove: [], + }) + + expect(changes).toContainEqual({ + upsert: [], + remove: ['term-oc'], + }) + expect(resolveOpencodeSessionRoots).not.toHaveBeenCalled() + + tracker.dispose() + }) + + it('filters child sessions from snapshot after session.created registers them', async () => { + vi.useFakeTimers() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/session/status')) { + return createJsonResponse({ + 'parent-1': { type: 'busy' }, + 'child-1': { type: 'busy' }, + }) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.created', + properties: { + sessionID: 'child-1', info: { id: 'child-1', parentID: 'parent-1' }, + }, + }, + ]) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + random: () => 0, + }) + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + tracker.on('changed', (payload) => changes.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(changes).toContainEqual({ + upsert: [{ + terminalId: 'term-oc', + sessionId: 'parent-1', + phase: 'busy', + updatedAt: expect.any(Number), + }], + remove: [], + }) + + tracker.dispose() + }) + + it('cleans up childSessionIds on untrackTerminal', async () => { + vi.useFakeTimers() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/session/status')) { + return createJsonResponse({}) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.created', + properties: { + sessionID: 'child-1', info: { id: 'child-1', parentID: 'parent-1' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'child-1', + status: { type: 'busy' }, + }, + }, + ]) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0 }) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + tracker.untrackTerminal({ terminalId: 'term-oc' }) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + tracker.on('changed', (payload) => changes.push(payload)) + + await vi.advanceTimersByTimeAsync(OPENCODE_RECONNECT_BASE_MS) + + tracker.dispose() + }) + + it('resets childSessionIds on trackTerminal early return when re-tracking same endpoint', async () => { + vi.useFakeTimers() + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return createJsonResponse({ ok: true }) + } + if (url.endsWith('/session/status')) { + return createJsonResponse({}) + } + if (url.endsWith('/event')) { + return createSseResponse([ + { type: 'server.connected', properties: {} }, + { + type: 'session.created', + properties: { + sessionID: 'child-1', info: { id: 'child-1', parentID: 'parent-1' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'child-1', + status: { type: 'busy' }, + }, + }, + { + type: 'session.status', + properties: { + sessionID: 'parent-1', + status: { type: 'busy' }, + }, + }, + ]) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const tracker = new OpencodeActivityTracker({ fetchImpl: fetchImpl as typeof fetch, random: () => 0 }) + const changes: Array<{ upsert: unknown[]; remove: string[] }> = [] + tracker.on('changed', (payload) => changes.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT, sessionId: 'parent-1' }) + await vi.advanceTimersByTimeAsync(0) + + tracker.dispose() + }) + + it('maps snapshot child activity to its OpenCode root before ownership reduction', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async () => ({ + rootsBySessionId: new Map([ + ['child-session', 'root-session'], + ]), + unresolvedSessionIds: new Set<string>(), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/session/status')) { + return createJsonResponse({ + 'child-session': { type: 'busy' }, + }) + } + if (url.endsWith('/event')) return createSseResponse([{ type: 'server.connected', properties: {} }]) + throw new Error(`Unexpected URL: ${url}`) + }) + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + random: () => 0, + resolveOpencodeSessionRoots, + }) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(resolveOpencodeSessionRoots).toHaveBeenCalledWith(['child-session']) + expect(tracker.list()).toEqual([ + expect.objectContaining({ + terminalId: 'term-oc', + sessionId: 'root-session', + phase: 'busy', + }), + ]) + + tracker.dispose() + }) + + it('does not adopt an unresolved singleton OpenCode snapshot as a durable session', async () => { + vi.useFakeTimers() + const resolveOpencodeSessionRoots = vi.fn(async () => ({ + rootsBySessionId: new Map<string, string>(), + unresolvedSessionIds: new Set(['child-session']), + })) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) return createJsonResponse({ ok: true }) + if (url.endsWith('/session/status')) { + return createJsonResponse({ + 'child-session': { type: 'busy' }, + }) + } + if (url.endsWith('/event')) return createSseResponse([{ type: 'server.connected', properties: {} }]) + throw new Error(`Unexpected URL: ${url}`) + }) + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + random: () => 0, + resolveOpencodeSessionRoots, + }) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: TEST_ENDPOINT }) + await vi.advanceTimersByTimeAsync(0) + + expect(tracker.list()).toEqual([ + expect.objectContaining({ + terminalId: 'term-oc', + phase: 'busy', + }), + ]) + expect(tracker.list()[0]).not.toHaveProperty('sessionId') + + tracker.dispose() + }) }) diff --git a/test/unit/server/coding-cli/opencode-activity-wiring.test.ts b/test/unit/server/coding-cli/opencode-activity-wiring.test.ts new file mode 100644 index 000000000..8f116fba5 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-activity-wiring.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { wireOpencodeActivityTracker } from '../../../../server/coding-cli/opencode-activity-wiring.js' + +function makeRegistry(record: any) { + const registry = new EventEmitter() as any + registry.list = vi.fn(() => []) + registry.get = vi.fn((terminalId: string) => ( + terminalId === record.terminalId ? record : undefined + )) + registry.bindSession = vi.fn(() => ({ ok: true })) + registry.rebindSession = vi.fn(() => ({ ok: true })) + return registry +} + +describe('wireOpencodeActivityTracker', () => { + it('notifies lifecycle callbacks for association', () => { + const terminal = { + terminalId: 'term-opencode-1', + mode: 'opencode', + status: 'running', + resumeSessionId: undefined, + opencodeServer: { hostname: '127.0.0.1', port: 32123 }, + } + const registry = makeRegistry(terminal) + const now = vi.fn(() => 12_345) + const onAssociated = vi.fn() + const wired = wireOpencodeActivityTracker({ + registry, + now, + onAssociated, + }) + + try { + wired.tracker.emit('association.requested', { + terminalId: 'term-opencode-1', + sessionId: 'ses_open_1', + }) + + expect(onAssociated).toHaveBeenCalledWith({ + terminalId: 'term-opencode-1', + sessionId: 'ses_open_1', + }) + } finally { + wired.dispose() + } + }) +}) diff --git a/test/unit/server/coding-cli/opencode-ownership-reducer.test.ts b/test/unit/server/coding-cli/opencode-ownership-reducer.test.ts new file mode 100644 index 000000000..dde36c36f --- /dev/null +++ b/test/unit/server/coding-cli/opencode-ownership-reducer.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, it } from 'vitest' +import { + confirmOpencodeAssociation, + createOpencodeOwnershipState, + reduceOpencodeOwnership, +} from '../../../../server/coding-cli/opencode-ownership-reducer' + +describe('opencode ownership reducer', () => { + it('requests association before completing a fresh live candidate', () => { + let state = createOpencodeOwnershipState() + + let result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }) + state = result.state + expect(result.actions).toContainEqual({ + kind: 'activityUpsert', + sessionId: 'session-a', + at: 10, + }) + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'idle', + at: 20, + }) + state = result.state + + expect(result.actions).toEqual([ + { kind: 'activityRemove', at: 20 }, + { kind: 'requestAssociation', sessionId: 'session-a' }, + ]) + + result = confirmOpencodeAssociation(state, { sessionId: 'session-a' }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: 'session-a', + }) + expect(result.actions).toEqual([ + { + kind: 'turnComplete', + sessionId: 'session-a', + at: 20, + }, + ]) + }) + + it('completes a known busy interval only from the same live stream', () => { + let state = createOpencodeOwnershipState('session-a') + + let result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 2, + sessionId: 'session-a', + status: 'idle', + at: 20, + }) + + expect(result.state).toEqual(state) + expect(result.actions).toEqual([]) + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'idle', + at: 30, + }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: 'session-a', + }) + expect(result.actions).toEqual([ + { kind: 'activityRemove', at: 30 }, + { kind: 'turnComplete', sessionId: 'session-a', at: 30 }, + ]) + }) + + it('treats competing candidate sessions as durable ambiguity and blocks third-session adoption until quiet', () => { + let state = createOpencodeOwnershipState() + + let result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }) + state = result.state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + expect(result.actions).toContainEqual({ + kind: 'activityUpsert', + at: 11, + }) + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-c', + status: 'busy', + at: 12, + }) + state = result.state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b', 'session-c'], + since: 11, + }) + expect(result.actions).not.toContainEqual(expect.objectContaining({ + kind: 'requestAssociation', + })) + + result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: {}, + at: 30, + }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: undefined, + }) + expect(result.actions).toEqual([{ kind: 'activityRemove', at: 30 }]) + }) + + it('clears ambiguous ownership after every blocked session idles on the live stream', () => { + let state = createOpencodeOwnershipState() + + let result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }) + state = result.state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'idle', + at: 20, + }) + state = result.state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-b'], + since: 11, + }) + expect(result.actions).toEqual([{ kind: 'activityUpsert', at: 20 }]) + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'idle', + at: 21, + }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: undefined, + }) + expect(result.actions).toEqual([{ kind: 'activityRemove', at: 21 }]) + }) + + it('never emits turn completion from snapshots', () => { + let state = createOpencodeOwnershipState('session-a') + + let result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 2, + streamId: 2, + statuses: {}, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: 'session-a', + }) + expect(result.actions).toEqual([{ kind: 'activityRemove', at: 20 }]) + expect(result.actions).not.toContainEqual(expect.objectContaining({ + kind: 'turnComplete', + })) + }) + + it('requests association for a snapshot-seeded candidate that idles on the same live stream', () => { + let state = createOpencodeOwnershipState() + + let result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'idle', + at: 20, + }) + state = result.state + + expect(result.actions).toEqual([ + { kind: 'activityRemove', at: 20 }, + { kind: 'requestAssociation', sessionId: 'session-a' }, + ]) + + result = confirmOpencodeAssociation(state, { sessionId: 'session-a' }) + + expect(result.state).toEqual({ + kind: 'quiet', + knownSessionId: 'session-a', + }) + expect(result.actions).toEqual([ + { kind: 'turnComplete', sessionId: 'session-a', at: 20 }, + ]) + }) + + it('ignores a stale-stream idle for a snapshot-seeded busy interval', () => { + let state = createOpencodeOwnershipState() + + let result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 10, + }) + state = result.state + + result = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 2, + sessionId: 'session-a', + status: 'idle', + at: 20, + }) + + expect(result.state).toEqual(state) + expect(result.actions).toEqual([]) + }) + + it('recomputes blockedSessionIds from snapshot instead of unioning stale sessions', () => { + let state = createOpencodeOwnershipState() + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }).state + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }).state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + const result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'candidate', + sessionId: 'session-a', + startedBy: 'snapshot', + cycleId: 1, + streamId: 1, + }) + }) + + it('transitions from ambiguous to knownBusy when snapshot shows single known session', () => { + let state = createOpencodeOwnershipState('session-a') + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }).state + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }).state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: 'session-a', + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + const result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'knownBusy', + sessionId: 'session-a', + startedBy: 'snapshot', + cycleId: 1, + streamId: 1, + }) + expect(result.actions).toEqual([ + { kind: 'activityUpsert', sessionId: 'session-a', at: 20 }, + ]) + }) + + it('transitions from ambiguous to candidate when snapshot shows single unknown session', () => { + let state = createOpencodeOwnershipState() + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }).state + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }).state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + const result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + }, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'candidate', + sessionId: 'session-a', + startedBy: 'snapshot', + cycleId: 1, + streamId: 1, + }) + }) + + it('does not re-emit warnAmbiguous when snapshot shows same set of sessions', () => { + let state = createOpencodeOwnershipState() + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }).state + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }).state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + const result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + 'session-b': { type: 'busy' }, + }, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + expect(result.actions).toEqual([ + { kind: 'activityUpsert', at: 20 }, + ]) + }) + + it('re-emits warnAmbiguous when snapshot shows different set of sessions', () => { + let state = createOpencodeOwnershipState() + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-a', + status: 'busy', + at: 10, + }).state + + state = reduceOpencodeOwnership(state, { + kind: 'sse', + cycleId: 1, + streamId: 1, + sessionId: 'session-b', + status: 'busy', + at: 11, + }).state + + expect(state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-b'], + since: 11, + }) + + const result = reduceOpencodeOwnership(state, { + kind: 'snapshot', + cycleId: 1, + streamId: 1, + statuses: { + 'session-a': { type: 'busy' }, + 'session-c': { type: 'busy' }, + }, + at: 20, + }) + + expect(result.state).toEqual({ + kind: 'ambiguous', + knownSessionId: undefined, + blockedSessionIds: ['session-a', 'session-c'], + since: 11, + }) + expect(result.actions).toEqual([ + { kind: 'activityUpsert', at: 20 }, + { kind: 'warnAmbiguous', sessionIds: ['session-a', 'session-c'] }, + ]) + }) +}) diff --git a/test/unit/server/coding-cli/opencode-provider.test.ts b/test/unit/server/coding-cli/opencode-provider.test.ts index 11963659f..79fc50bf0 100644 --- a/test/unit/server/coding-cli/opencode-provider.test.ts +++ b/test/unit/server/coding-cli/opencode-provider.test.ts @@ -3,6 +3,20 @@ import os from 'os' import fsp from 'fs/promises' import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +const loggerMock = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + child: vi.fn(), +})) +loggerMock.child.mockReturnValue(loggerMock) + +vi.mock('../../../../server/logger.js', () => ({ + logger: loggerMock, + sessionLifecycleLogger: loggerMock, +})) + type FakeProjectRow = { id: string worktree: string @@ -22,27 +36,71 @@ type FakeSessionRow = { const fakeDatabaseState = new Map<string, { projects: FakeProjectRow[] sessions: FakeSessionRow[] + hasParentId?: boolean }>() class FakeDatabaseSync { + private static openFailures: Error[] = [] + static seed( dbPath: string, rows: { projects: FakeProjectRow[] sessions: FakeSessionRow[] + hasParentId?: boolean }, ): void { fakeDatabaseState.set(dbPath, rows) } - constructor(private readonly dbPath: string) {} + static failOpenOnce(err: Error): void { + this.openFailures.push(err) + } - prepare() { + static reset(): void { + this.openFailures = [] + } + + constructor(private readonly dbPath: string) { + const failure = FakeDatabaseSync.openFailures.shift() + if (failure) throw failure + } + + prepare(sql: string) { return { - all: () => { - const rows = fakeDatabaseState.get(this.dbPath) ?? { projects: [], sessions: [] } + all: (...params: unknown[]) => { + const rows = fakeDatabaseState.get(this.dbPath) ?? { projects: [], sessions: [], hasParentId: true } + const hasParentId = rows.hasParentId ?? true + if (/PRAGMA\s+table_info\(session\)/i.test(sql)) { + return [ + { name: 'id' }, + { name: 'project_id' }, + ...(hasParentId ? [{ name: 'parent_id' }] : []), + { name: 'directory' }, + { name: 'title' }, + { name: 'time_created' }, + { name: 'time_updated' }, + { name: 'time_archived' }, + ] + } + if (/SELECT\s+id,\s+parent_id\s+FROM\s+session/i.test(sql)) { + if (!hasParentId) throw new Error('no such column: parent_id') + const requested = new Set(params) + return rows.sessions + .filter((session) => requested.has(session.id)) + .map((session) => ({ + id: session.id, + parent_id: session.parent_id, + })) + } + if (!hasParentId && /parent_id/i.test(sql)) { + throw new Error('no such column: parent_id') + } return rows.sessions - .filter((session) => session.parent_id === null && session.time_archived === null) + .filter((session) => ( + session.time_archived === null + && (!hasParentId || session.parent_id === null) + )) .sort((left, right) => (right.time_updated ?? 0) - (left.time_updated ?? 0)) .map((session) => ({ sessionId: session.id, @@ -71,10 +129,13 @@ describe('OpencodeProvider', () => { beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-provider-')) fakeDatabaseState.clear() + FakeDatabaseSync.reset() + vi.clearAllMocks() }) afterEach(async () => { fakeDatabaseState.clear() + FakeDatabaseSync.reset() await fsp.rm(tempDir, { recursive: true, force: true }) }) @@ -136,4 +197,148 @@ describe('OpencodeProvider', () => { }, ]) }) + + it('watches OpenCode sqlite database and WAL but not SHM', () => { + const provider = new OpencodeProvider(tempDir) + const dbPath = path.join(tempDir, 'opencode.db') + const glob = provider.getSessionGlob() + + expect(glob).toContain('opencode.db') + expect(glob).toContain('opencode.db-wal') + expect(glob).not.toContain('opencode.db-shm') + expect(glob).not.toContain('*') + expect(provider.getSessionRoots()).toEqual([dbPath]) + expect(provider.getSessionWatchBases()).toEqual([path.dirname(tempDir)]) + }) + + it('logs missing OpenCode database as unavailable, not as a successful empty session list', async () => { + const provider = new OpencodeProvider(tempDir) + + await expect(provider.listSessionsDirect()).resolves.toEqual([]) + + expect(loggerMock.info).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'opencode', + dbPathLabel: '<opencode-data>/opencode.db', + dbFile: 'opencode.db', + pathSanitized: true, + messageClass: 'missing_db', + }), 'OpenCode sessions database is not available') + expect(JSON.stringify(loggerMock.info.mock.calls)).not.toContain(tempDir) + expect(JSON.stringify(loggerMock.info.mock.calls)).not.toContain(os.tmpdir()) + }) + + it('logs OpenCode database read failures distinctly from an empty database', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') + FakeDatabaseSync.failOpenOnce(new Error('bad sqlite')) + const provider = new OpencodeProvider(tempDir) + + await expect(provider.listSessionsDirect()).resolves.toEqual([]) + + expect(loggerMock.warn).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'opencode', + dbPathLabel: '<opencode-data>/opencode.db', + dbFile: 'opencode.db', + pathSanitized: true, + errorName: 'Error', + messageClass: 'sqlite_open_failed', + }), 'Failed to read OpenCode sessions database') + const serializedCalls = JSON.stringify(loggerMock.warn.mock.calls) + expect(serializedCalls).not.toContain(tempDir) + expect(serializedCalls).not.toContain(os.tmpdir()) + expect(serializedCalls).not.toContain('bad sqlite') + }) + + it('logs an empty OpenCode database as empty, not broken', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') + FakeDatabaseSync.seed(dbPath, { + projects: [], + sessions: [], + }) + const provider = new OpencodeProvider(tempDir) + + await expect(provider.listSessionsDirect()).resolves.toEqual([]) + + expect(loggerMock.info).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'opencode', + dbPathLabel: '<opencode-data>/opencode.db', + dbFile: 'opencode.db', + pathSanitized: true, + messageClass: 'empty_db', + rowCount: 0, + }), 'OpenCode sessions database has no active root sessions') + }) + + it('maps OpenCode child session ids to sqlite root session ids', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') + FakeDatabaseSync.seed(dbPath, { + projects: [], + sessions: [ + { + id: 'root_session', + project_id: 'project-1', + parent_id: null, + directory: '/repo/root', + title: 'Root', + time_created: 1000, + time_updated: 2000, + time_archived: null, + }, + { + id: 'child_session', + project_id: 'project-1', + parent_id: 'root_session', + directory: '/repo/root', + title: 'Child', + time_created: 1001, + time_updated: 2001, + time_archived: null, + }, + ], + }) + + const provider = new OpencodeProvider(tempDir) + const resolved = await provider.resolveOpencodeSessionRoots(['child_session']) + + expect(resolved.rootsBySessionId.get('child_session')).toBe('root_session') + expect(resolved.unresolvedSessionIds.size).toBe(0) + }) + + it('treats an OpenCode schema without parent_id as flat roots', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') + FakeDatabaseSync.seed(dbPath, { + projects: [ + { id: 'project-1', worktree: '/repo/root' }, + ], + hasParentId: false, + sessions: [ + { + id: 'flat_session', + project_id: 'project-1', + parent_id: null, + directory: '/repo/root', + title: 'Flat', + time_created: 1000, + time_updated: 2000, + time_archived: null, + }, + ], + }) + + const provider = new OpencodeProvider(tempDir) + const resolved = await provider.resolveOpencodeSessionRoots(['flat_session']) + const sessions = await provider.listSessionsDirect() + + expect(resolved.rootsBySessionId.get('flat_session')).toBe('flat_session') + expect(resolved.unresolvedSessionIds.size).toBe(0) + expect(sessions).toEqual([ + expect.objectContaining({ + provider: 'opencode', + sessionId: 'flat_session', + }), + ]) + }) }) diff --git a/test/unit/server/coding-cli/opencode-session-controller.test.ts b/test/unit/server/coding-cli/opencode-session-controller.test.ts new file mode 100644 index 000000000..a15479f42 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-session-controller.test.ts @@ -0,0 +1,233 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' +import { OpencodeSessionController } from '../../../../server/coding-cli/opencode-session-controller' + +class FakeTracker extends EventEmitter { + confirmSessionAssociation = vi.fn() + rejectSessionAssociation = vi.fn() +} + +function makeRegistry(input: { + get?: ReturnType<typeof vi.fn> + bindSession?: ReturnType<typeof vi.fn> +} = {}) { + return { + get: input.get ?? vi.fn(() => ({ + terminalId: 'term-1', + mode: 'opencode', + status: 'running', + resumeSessionId: undefined, + })), + bindSession: input.bindSession ?? vi.fn(() => ({ + ok: true as const, + terminalId: 'term-1', + sessionId: 'session-1', + })), + rebindSession: vi.fn(() => { + throw new Error('rebindSession must not be used for OpenCode control-plane adoption') + }), + on: vi.fn(), + off: vi.fn(), + } +} + +describe('OpencodeSessionController', () => { + it('uses non-stealing bindSession and confirms successful association requests', () => { + const tracker = new FakeTracker() + const registry = makeRegistry() + const associated = vi.fn() + const controller = new OpencodeSessionController({ + tracker: tracker as any, + registry: registry as any, + }) + controller.on('associated', associated) + + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'session-1', + }) + + expect(registry.bindSession).toHaveBeenCalledWith('term-1', 'opencode', 'session-1', 'association') + expect(registry.rebindSession).not.toHaveBeenCalled() + expect(tracker.confirmSessionAssociation).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + }) + expect(tracker.rejectSessionAssociation).not.toHaveBeenCalled() + expect(associated).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + }) + + controller.dispose() + }) + + it('does not repeat binding or association emission for the same terminal/session pair', () => { + const tracker = new FakeTracker() + const registry = makeRegistry() + const associated = vi.fn() + const controller = new OpencodeSessionController({ + tracker: tracker as any, + registry: registry as any, + }) + controller.on('associated', associated) + + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'session-1', + }) + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'session-1', + }) + + expect(registry.bindSession).toHaveBeenCalledTimes(1) + expect(tracker.confirmSessionAssociation).toHaveBeenCalledTimes(2) + expect(associated).toHaveBeenCalledTimes(1) + + controller.dispose() + }) + + it.each([ + { + name: 'missing terminal', + terminal: undefined, + reason: 'terminal_missing_or_not_running', + extra: {}, + }, + { + name: 'non-OpenCode terminal', + terminal: { + terminalId: 'term-1', + mode: 'codex', + status: 'running', + resumeSessionId: undefined, + }, + reason: 'terminal_not_opencode', + extra: { mode: 'codex' }, + }, + { + name: 'stopped terminal', + terminal: { + terminalId: 'term-1', + mode: 'opencode', + status: 'exited', + resumeSessionId: undefined, + }, + reason: 'terminal_missing_or_not_running', + extra: { status: 'exited' }, + }, + ])('logs and rejects association requests for $name', ({ terminal, reason, extra }) => { + const tracker = new FakeTracker() + const registry = makeRegistry({ + get: vi.fn(() => terminal), + }) + const log = { warn: vi.fn() } + const controller = new OpencodeSessionController({ + tracker: tracker as any, + registry: registry as any, + log, + }) + + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'session-1', + }) + + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + reason, + ...extra, + }, 'Rejected OpenCode association request') + expect(registry.bindSession).not.toHaveBeenCalled() + expect(tracker.confirmSessionAssociation).not.toHaveBeenCalled() + expect(tracker.rejectSessionAssociation).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + }) + + controller.dispose() + }) + + it('rejects association requests when bindSession detects an ownership conflict', () => { + const tracker = new FakeTracker() + const registry = makeRegistry({ + bindSession: vi.fn(() => ({ + ok: false as const, + reason: 'session_already_owned', + owner: 'other-terminal', + })), + }) + const log = { warn: vi.fn() } + const associated = vi.fn() + const controller = new OpencodeSessionController({ + tracker: tracker as any, + registry: registry as any, + log, + }) + controller.on('associated', associated) + + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'session-1', + }) + + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'session_already_owned', + ownerTerminalId: 'other-terminal', + }, 'Rejected OpenCode association request') + expect(tracker.confirmSessionAssociation).not.toHaveBeenCalled() + expect(tracker.rejectSessionAssociation).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + }) + expect(associated).not.toHaveBeenCalled() + + controller.dispose() + }) + + it('logs rejected association requests with previous session context', () => { + const tracker = new FakeTracker() + const log = { warn: vi.fn() } + const registry = makeRegistry({ + get: vi.fn(() => ({ + terminalId: 'term-1', + mode: 'opencode', + status: 'running', + resumeSessionId: 'previous-session', + })), + bindSession: vi.fn(() => ({ + ok: false as const, + reason: 'session_already_owned', + owner: 'other-terminal', + })), + }) + const controller = new OpencodeSessionController({ + tracker: tracker as any, + registry: registry as any, + log, + }) + + tracker.emit('association.requested', { + terminalId: 'term-1', + sessionId: 'next-session', + }) + + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'next-session', + reason: 'session_already_owned', + previousSessionId: 'previous-session', + ownerTerminalId: 'other-terminal', + }, 'Rejected OpenCode association request') + expect(tracker.rejectSessionAssociation).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'next-session', + }) + + controller.dispose() + }) +}) diff --git a/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts b/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts new file mode 100644 index 000000000..aaa39fd80 --- /dev/null +++ b/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CodingCliSessionIndexer } from '../../../../server/coding-cli/session-indexer.js' +import type { CodingCliProvider } from '../../../../server/coding-cli/provider.js' + +vi.mock('../../../../server/config-store', () => ({ + configStore: { + getProjectColors: vi.fn().mockResolvedValue({}), + snapshot: vi.fn().mockResolvedValue({ + settings: { + codingCli: { + enabledProviders: ['opencode'], + providers: {}, + }, + }, + }), + }, +})) + +function makeDirectProvider(): CodingCliProvider { + return { + name: 'opencode', + displayName: 'OpenCode', + homeDir: '/tmp/opencode-home', + listSessionsDirect: vi.fn(async () => []), + getSessionGlob: () => '/tmp/opencode-home/{opencode.db,opencode.db-wal}', + getSessionRoots: () => ['/tmp/opencode-home/opencode.db'], + getSessionWatchBases: () => ['/tmp'], + listSessionFiles: async () => [], + parseSessionFile: async () => ({}), + resolveProjectPath: async () => '/tmp/opencode-home', + extractSessionId: () => 'unused', + getCommand: () => 'opencode', + getStreamArgs: () => [], + getResumeArgs: (sessionId: string) => ['--session', sessionId], + parseEvent: () => [], + supportsLiveStreaming: () => false, + supportsSessionResume: () => true, + } +} + +describe('CodingCliSessionIndexer provider refresh', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('coalesces urgent direct-provider refresh requests', async () => { + const provider = makeDirectProvider() + const indexer = new CodingCliSessionIndexer([provider], { + debounceMs: 100, + throttleMs: 1000, + }) + + indexer.scheduleProviderRefresh('opencode', { urgent: true, reason: 'turn_complete' }) + indexer.scheduleProviderRefresh('opencode', { urgent: true, reason: 'association' }) + + await vi.runAllTimersAsync() + await Promise.resolve() + + expect(provider.listSessionsDirect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/server/config-store.fresh-agent-settings.test.ts b/test/unit/server/config-store.fresh-agent-settings.test.ts new file mode 100644 index 000000000..84efdb869 --- /dev/null +++ b/test/unit/server/config-store.fresh-agent-settings.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { createDefaultServerSettings, mergeServerSettings } from '@shared/settings' + +describe('config-store fresh-agent settings compatibility', () => { + it('migrates legacy settings.agentChat to settings.freshAgent', () => { + const settings = mergeServerSettings( + createDefaultServerSettings({ loggingDebug: false }), + { + agentChat: { + defaultPlugins: ['/tmp/plugin'], + providers: { + freshclaude: { defaultModel: 'fixture-claude-model', defaultEffort: 'high' }, + }, + }, + }, + ) + + expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) + expect(settings.agentChat.defaultPlugins).toEqual(['/tmp/plugin']) + expect(settings.freshAgent.providers.freshclaude).toEqual({ + modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' }, + effort: 'high', + }) + expect(settings.agentChat.providers.freshclaude).toEqual({ + modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' }, + effort: 'high', + }) + }) +}) diff --git a/test/unit/server/config-store.test.ts b/test/unit/server/config-store.test.ts index 11a08c813..bf64fad3f 100644 --- a/test/unit/server/config-store.test.ts +++ b/test/unit/server/config-store.test.ts @@ -225,6 +225,10 @@ describe('ConfigStore', () => { ...defaultSettings.agentChat, defaultPlugins: ['fs'], }, + freshAgent: { + ...defaultSettings.freshAgent, + defaultPlugins: ['fs'], + }, }) expect(config.legacyLocalSettingsSeed).toEqual({ theme: 'dark', diff --git a/test/unit/server/fresh-agent/claude-adapter.test.ts b/test/unit/server/fresh-agent/claude-adapter.test.ts new file mode 100644 index 000000000..c6762b596 --- /dev/null +++ b/test/unit/server/fresh-agent/claude-adapter.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createClaudeFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/claude/adapter.js' + +describe('Claude fresh-agent adapter', () => { + it('delegates create, resume, send, interrupt, and interactive responses to the sdk bridge', async () => { + const sdkBridge = { + createSession: vi.fn().mockResolvedValue({ sessionId: 'sdk-claude-1' }), + subscribe: vi.fn().mockReturnValue({ off: vi.fn(), replayed: false }), + sendUserMessage: vi.fn().mockReturnValue(true), + interrupt: vi.fn().mockReturnValue(true), + respondQuestion: vi.fn().mockReturnValue(true), + respondPermission: vi.fn().mockReturnValue(true), + } + + const adapter = createClaudeFreshAgentAdapter({ + sdkBridge: sdkBridge as any, + timelineService: { + getSnapshot: vi.fn(), + getTimelinePage: vi.fn(), + getTurnBody: vi.fn(), + } as any, + }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshclaude', + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a'], + })).resolves.toEqual({ sessionId: 'sdk-claude-1' }) + + await expect(adapter.resume?.({ + requestId: 'req-2', + sessionType: 'freshclaude', + resumeSessionId: 'resume-claude-1', + })).resolves.toEqual({ sessionId: 'sdk-claude-1' }) + + const listener = vi.fn() + const off = await adapter.subscribe?.('sdk-claude-1', listener) + await adapter.send?.('sdk-claude-1', { text: 'hello' }) + await adapter.interrupt?.('sdk-claude-1') + await adapter.answerQuestion?.('sdk-claude-1', 'question-1', { Proceed: 'Yes' }) + await adapter.resolveApproval?.('sdk-claude-1', 'approval-1', { behavior: 'allow' }) + + expect(typeof off).toBe('function') + expect(sdkBridge.createSession).toHaveBeenNthCalledWith(1, expect.objectContaining({ + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a'], + })) + expect(sdkBridge.createSession).toHaveBeenNthCalledWith(2, expect.objectContaining({ + resumeSessionId: 'resume-claude-1', + })) + expect(sdkBridge.subscribe).toHaveBeenCalledWith('sdk-claude-1', listener) + expect(sdkBridge.sendUserMessage).toHaveBeenCalledWith('sdk-claude-1', 'hello', undefined) + expect(sdkBridge.interrupt).toHaveBeenCalledWith('sdk-claude-1') + expect(sdkBridge.respondQuestion).toHaveBeenCalledWith('sdk-claude-1', 'question-1', { Proceed: 'Yes' }) + expect(sdkBridge.respondPermission).toHaveBeenCalledWith('sdk-claude-1', 'approval-1', { behavior: 'allow' }) + }) +}) diff --git a/test/unit/server/fresh-agent/claude-normalize.test.ts b/test/unit/server/fresh-agent/claude-normalize.test.ts new file mode 100644 index 000000000..ca7333e52 --- /dev/null +++ b/test/unit/server/fresh-agent/claude-normalize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeClaudeThreadSnapshot } from '../../../../server/fresh-agent/adapters/claude/normalize.js' +import { makeClaudeLiveSession, makeClaudeRestoreResolution } from '../../../fixtures/fresh-agent/claude/thread.js' + +describe('Claude fresh-agent normalization', () => { + it('normalizes Claude block messages into shared fresh-agent items and metadata', () => { + const snapshot = normalizeClaudeThreadSnapshot({ + threadId: 'sdk-claude-1', + liveSession: makeClaudeLiveSession(), + resolved: makeClaudeRestoreResolution(), + status: 'running', + }) + + expect(snapshot.turns.map((turn) => turn.source)).toEqual(['durable', 'live']) + expect(snapshot.turns[1]?.items.map((item) => item.kind)).toEqual([ + 'thinking', + 'tool_use', + 'tool_result', + 'text', + ]) + expect(snapshot.pendingApprovals).toEqual([ + expect.objectContaining({ + requestId: 'approval-1', + toolName: 'Bash', + decisionReason: 'Needs approval', + }), + ]) + expect(snapshot.pendingQuestions).toEqual([ + expect.objectContaining({ + requestId: 'question-1', + }), + ]) + expect(snapshot.settings).toMatchObject({ + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a', '/tmp/plugin-b'], + }) + expect(snapshot.tokenUsage).toEqual({ + inputTokens: 12, + outputTokens: 34, + totalTokens: 46, + costUsd: 1.25, + }) + }) +}) diff --git a/test/unit/server/fresh-agent/claude-restore-contract.test.ts b/test/unit/server/fresh-agent/claude-restore-contract.test.ts new file mode 100644 index 000000000..fd2b0c7e7 --- /dev/null +++ b/test/unit/server/fresh-agent/claude-restore-contract.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createAgentHistorySource } from '../../../../server/agent-timeline/history-source.js' +import { createAgentTimelineService } from '../../../../server/agent-timeline/service.js' +import { createClaudeFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/claude/adapter.js' +import { makeClaudeLiveSession } from '../../../fixtures/fresh-agent/claude/thread.js' +import type { ChatMessage } from '../../../../server/session-history-loader.js' + +function makeMessage( + role: 'user' | 'assistant', + text: string, + options: Partial<ChatMessage> = {}, +): ChatMessage { + return { + role, + content: [{ type: 'text', text }], + timestamp: '2026-04-18T12:00:00.000Z', + ...options, + } +} + +describe('Claude fresh-agent restore contract', () => { + it('merges ledger-backed restore state and live stream into one canonical snapshot', async () => { + const liveSession = makeClaudeLiveSession({ + messages: [ + makeMessage('assistant', 'Live reply', { messageId: 'live-2' }), + ], + }) + const historySource = createAgentHistorySource({ + loadSessionHistory: vi.fn().mockResolvedValue([ + makeMessage('user', 'Durable prompt', { messageId: 'durable-1' }), + ]), + getLiveSessionBySdkSessionId: vi.fn((sessionId: string) => ( + sessionId === 'sdk-claude-1' ? liveSession : undefined + )), + getLiveSessionByCliSessionId: vi.fn((sessionId: string) => ( + sessionId === '00000000-0000-4000-8000-000000000111' ? liveSession : undefined + )), + }) + const timelineService = createAgentTimelineService({ + agentHistorySource: historySource, + }) + const adapter = createClaudeFreshAgentAdapter({ + sdkBridge: { + getSession: vi.fn((sessionId: string) => (sessionId === 'sdk-claude-1' ? liveSession : undefined)), + findSessionByCliSessionId: vi.fn((sessionId: string) => ( + sessionId === '00000000-0000-4000-8000-000000000111' ? liveSession : undefined + )), + } as any, + agentHistorySource: historySource, + timelineService, + }) + + const snapshot = await adapter.getSnapshot?.({ + sessionType: 'freshclaude', + provider: 'claude', + threadId: 'sdk-claude-1', + }) + + expect(snapshot).toMatchObject({ + provider: 'claude', + threadId: 'sdk-claude-1', + revision: expect.any(Number), + latestTurnId: 'turn:live-2', + }) + expect(snapshot?.turns.map((turn: { source: string }) => turn.source)).toEqual(['durable', 'live']) + expect(snapshot?.extensions.claude).toMatchObject({ + timelineSessionId: '00000000-0000-4000-8000-000000000111', + readiness: 'merged', + }) + }) +}) diff --git a/test/unit/server/fresh-agent/codex-adapter.test.ts b/test/unit/server/fresh-agent/codex-adapter.test.ts new file mode 100644 index 000000000..c97bd4163 --- /dev/null +++ b/test/unit/server/fresh-agent/codex-adapter.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCodexFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/codex/adapter.js' + +function makeCodexThread(id: string) { + return { + id, + sessionId: id, + preview: 'Codex summary', + ephemeral: false, + modelProvider: 'openai', + createdAt: 1770000000, + updatedAt: 7, + status: { type: 'idle' }, + cwd: '/repo', + cliVersion: 'codex-cli 0.129.0', + source: 'appServer', + turns: [], + } +} + +function makeCodexTurn(id: string) { + return { + id, + status: 'completed', + items: [{ + type: 'agentMessage', + id: `${id}:item-1`, + text: 'Codex summary', + phase: null, + memoryCitation: null, + }], + } +} + +describe('Codex fresh-agent adapter', () => { + it('starts fresh Codex threads with generated app-server params', async () => { + const runtime = { + startThread: vi.fn().mockResolvedValue({ + threadId: 'thread-new-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + resumeThread: vi.fn().mockResolvedValue({ + threadId: 'thread-resume-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + readThread: vi.fn().mockResolvedValue({ + thread: makeCodexThread('thread-new-1'), + }), + listThreadTurns: vi.fn().mockResolvedValue({ turns: [], nextCursor: null, revision: 7 }), + readThreadTurn: vi.fn().mockResolvedValue(null), + } + const adapter = createCodexFreshAgentAdapter({ + runtime: runtime as any, + }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/repo', + permissionMode: 'on-request', + model: 'codex-fixture', + })).resolves.toEqual({ sessionId: 'thread-new-1', sessionRef: { provider: 'codex', sessionId: 'thread-new-1' } }) + + await expect(adapter.resume?.({ + requestId: 'req-2', + sessionType: 'freshcodex', + resumeSessionId: 'thread-resume-1', + cwd: '/repo', + permissionMode: 'never', + model: 'codex-fixture', + })).resolves.toEqual({ sessionId: 'thread-resume-1', sessionRef: { provider: 'codex', sessionId: 'thread-resume-1' } }) + + expect(runtime.startThread).toHaveBeenCalledWith(expect.objectContaining({ + cwd: '/repo', + model: 'codex-fixture', + approvalPolicy: 'on-request', + })) + expect(runtime.resumeThread).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-resume-1', + cwd: '/repo', + model: 'codex-fixture', + approvalPolicy: 'never', + })) + }) + + it('fails clearly for Claude-only Freshcodex approval policies', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + permissionMode: 'bypassPermissions', + })).rejects.toThrow('Freshcodex does not support approval policy "bypassPermissions"') + expect(runtime.startThread).not.toHaveBeenCalled() + }) + + it('reads snapshots and turns from the official Codex thread APIs', async () => { + const durableTurn = makeCodexTurn('turn-1') + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn().mockResolvedValue({ + thread: { + ...makeCodexThread('thread-new-1'), + turns: [durableTurn], + }, + }), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: null, + turns: [durableTurn], + }), + readThreadTurn: vi.fn().mockResolvedValue(durableTurn), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, 7)).resolves.toMatchObject({ + provider: 'codex', + threadId: 'thread-new-1', + revision: 7, + turns: [{ id: 'turn-1', turnId: 'turn-1' }], + }) + expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: true }) + await expect(adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7 })).resolves.toMatchObject({ + revision: 7, + turns: [{ id: 'turn-1', turnId: 'turn-1' }], + }) + await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' }, 7)).resolves.toMatchObject({ + turnId: 'turn-1', + revision: 7, + }) + }) + + it('subscribes to Codex lifecycle notifications and projects matching thread updates', async () => { + let lifecycleHandler: ((event: any) => void) | undefined + const off = vi.fn() + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + onThreadLifecycle: vi.fn((handler) => { + lifecycleHandler = handler + return off + }), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + const listener = vi.fn() + + const unsubscribe = await adapter.subscribe?.('thread-new-1', listener) + + expect(runtime.onThreadLifecycle).toHaveBeenCalledWith(expect.any(Function)) + + lifecycleHandler?.({ + kind: 'thread_status_changed', + threadId: 'other-thread', + status: { type: 'active', activeFlags: [] }, + }) + expect(listener).not.toHaveBeenCalled() + + lifecycleHandler?.({ + kind: 'thread_status_changed', + threadId: 'thread-new-1', + status: { type: 'active', activeFlags: [] }, + }) + lifecycleHandler?.({ + kind: 'thread_status_changed', + threadId: 'thread-new-1', + status: { type: 'idle' }, + }) + lifecycleHandler?.({ + kind: 'thread_closed', + threadId: 'thread-new-1', + }) + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ + type: 'sdk.session.snapshot', + sessionId: 'thread-new-1', + status: 'running', + })) + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ + type: 'sdk.session.snapshot', + sessionId: 'thread-new-1', + status: 'idle', + })) + expect(listener).toHaveBeenCalledWith({ + type: 'sdk.status', + sessionId: 'thread-new-1', + status: 'exited', + }) + + unsubscribe?.() + expect(off).toHaveBeenCalledTimes(1) + }) + + it('starts turns with Codex-shaped input/settings and interrupts the active turn', async () => { + const runtime = { + startThread: vi.fn().mockResolvedValue({ + threadId: 'thread-new-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + resumeThread: vi.fn(), + forkThread: vi.fn(), + startTurn: vi.fn().mockResolvedValue({ turnId: 'turn-active-1' }), + interruptTurn: vi.fn().mockResolvedValue(undefined), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/repo', + permissionMode: 'on-request', + sandbox: 'workspace-write', + effort: 'xhigh', + model: 'codex-fixture', + }) + + await adapter.send?.('thread-new-1', { + text: 'Review this image', + images: [{ kind: 'data', mediaType: 'image/png', data: 'abc123' }], + }) + await adapter.interrupt?.('thread-new-1') + + expect(runtime.startTurn).toHaveBeenCalledWith({ + threadId: 'thread-new-1', + input: [ + { type: 'text', text: 'Review this image', text_elements: [] }, + { type: 'image', url: 'data:image/png;base64,abc123' }, + ], + cwd: '/repo', + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'workspaceWrite' }, + model: 'codex-fixture', + effort: 'xhigh', + }) + expect(runtime.interruptTurn).toHaveBeenCalledWith({ + threadId: 'thread-new-1', + turnId: 'turn-active-1', + }) + }) + + it('rejects Claude-only Freshcodex effort values before app-server calls', async () => { + const runtime = { + startThread: vi.fn().mockResolvedValue({ + threadId: 'thread-new-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + resumeThread: vi.fn(), + forkThread: vi.fn(), + startTurn: vi.fn(), + interruptTurn: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + effort: 'max', + })).rejects.toThrow('Freshcodex does not support reasoning effort "max"') + expect(runtime.startThread).not.toHaveBeenCalled() + expect(runtime.startTurn).not.toHaveBeenCalled() + }) + + it('forks Codex threads with stored runtime settings and excludeTurns', async () => { + const runtime = { + startThread: vi.fn().mockResolvedValue({ + threadId: 'thread-new-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + resumeThread: vi.fn(), + forkThread: vi.fn().mockResolvedValue({ + threadId: 'thread-fork-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + startTurn: vi.fn(), + interruptTurn: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/repo', + model: 'codex-fixture', + permissionMode: 'never', + sandbox: 'read-only', + }) + + await expect(adapter.fork?.('thread-new-1')).resolves.toEqual({ + threadId: 'thread-fork-1', + wsUrl: 'ws://127.0.0.1:43123', + }) + expect(runtime.forkThread).toHaveBeenCalledWith({ + threadId: 'thread-new-1', + cwd: '/repo', + model: 'codex-fixture', + sandbox: 'read-only', + approvalPolicy: 'never', + excludeTurns: true, + }) + }) +}) diff --git a/test/unit/server/fresh-agent/codex-normalize.test.ts b/test/unit/server/fresh-agent/codex-normalize.test.ts new file mode 100644 index 000000000..98374fa8e --- /dev/null +++ b/test/unit/server/fresh-agent/codex-normalize.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeCodexThreadSnapshot } from '../../../../server/fresh-agent/adapters/codex/normalize.js' + +describe('Codex fresh-agent normalization', () => { + it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', () => { + const snapshot = normalizeCodexThreadSnapshot({ + threadId: 'thread-codex-1', + revision: 7, + status: 'idle', + transcript: { + turns: [ + { + id: 'turn-1', + turnId: 'turn-1', + messageId: 'msg-1', + ordinal: 0, + source: 'durable', + role: 'assistant', + summary: 'Codex finished a review pass', + items: [{ id: 'turn-1:item-0', kind: 'text', text: 'Codex finished a review pass.' }], + }, + ], + }, + rawSnapshot: { + summary: 'Codex finished a review pass', + tokenUsage: { + inputTokens: 10, + outputTokens: 6, + cachedTokens: 2, + totalTokens: 18, + contextTokens: 18, + compactPercent: 4, + }, + worktrees: [{ id: 'wt-1', path: '/repo/.worktrees/task-1', branch: 'feature/task-1' }], + diffs: [{ id: 'diff-1', path: 'src/app.ts', title: 'src/app.ts' }], + childThreads: [{ id: 'child-1', threadId: 'thread-child-1', origin: 'subagent', title: 'Review shell' }], + extension: { + codex: { + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }, + }, + }, + }) + + expect(snapshot.capabilities.send).toBe(true) + expect(snapshot.capabilities.interrupt).toBe(false) + expect(snapshot.capabilities.fork).toBe(true) + expect(snapshot.worktrees[0]?.path).toContain('.worktrees') + expect(snapshot.childThreads[0]?.origin).toBe('subagent') + expect(snapshot.extensions.codex).toMatchObject({ + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }) + expect(snapshot.diffs[0]).toMatchObject({ path: 'src/app.ts' }) + }) +}) diff --git a/test/unit/server/fresh-agent/production-wiring.test.ts b/test/unit/server/fresh-agent/production-wiring.test.ts new file mode 100644 index 000000000..e893ce202 --- /dev/null +++ b/test/unit/server/fresh-agent/production-wiring.test.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +const repoRoot = path.resolve(__dirname, '../../../..') + +describe('fresh-agent production wiring', () => { + it('wires the runtime manager, REST router, and WebSocket handler in server/index.ts', () => { + const source = fs.readFileSync(path.join(repoRoot, 'server/index.ts'), 'utf8') + + expect(source).toContain('FreshAgentRuntimeManager') + expect(source).toContain('createFreshAgentProviderRegistry') + expect(source).toContain('createFreshAgentRouter') + expect(source).toContain('createClaudeFreshAgentAdapter') + expect(source).toContain('createCodexFreshAgentAdapter') + expect(source).toMatch(/const freshAgentRuntimeManager = new FreshAgentRuntimeManager\(/) + expect(source).toMatch(/freshAgentRuntimeManager,\s*\n/) + expect(source).toMatch(/app\.use\('\/api', createFreshAgentRouter\(\{\s*runtimeManager: freshAgentRuntimeManager/) + expect(source).toContain('codexFreshAgentRuntime') + expect(source).toMatch(/codexFreshAgentRuntime,\s*\n\s*terminalShutdownTimeoutMs/) + }) +}) diff --git a/test/unit/server/fresh-agent/router.test.ts b/test/unit/server/fresh-agent/router.test.ts new file mode 100644 index 000000000..93eaf52f0 --- /dev/null +++ b/test/unit/server/fresh-agent/router.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' + +import { createFreshAgentRouter } from '../../../../server/fresh-agent/router.js' +import { FreshAgentRuntimeManager, FreshAgentStaleThreadRevisionError } from '../../../../server/fresh-agent/runtime-manager.js' + +describe('fresh-agent router', () => { + it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => { + const manager = { + getTurnBody: vi.fn().mockRejectedValue(new FreshAgentStaleThreadRevisionError(7)), + } as unknown as FreshAgentRuntimeManager + + const app = express() + app.use('/api', createFreshAgentRouter({ runtimeManager: manager })) + + const response = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-1/turns/turn-9?revision=4') + + expect(response.status).toBe(409) + expect(response.body.code).toBe('STALE_THREAD_REVISION') + expect(response.body.currentRevision).toBe(7) + }) +}) diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts new file mode 100644 index 000000000..217cc88f1 --- /dev/null +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' + +import { FreshAgentRuntimeManager } from '../../../../server/fresh-agent/runtime-manager.js' +import { createFreshAgentProviderRegistry } from '../../../../server/fresh-agent/provider-registry.js' + +function makeSnapshot(sessionType: 'freshclaude' | 'kilroy', provider: 'claude', threadId: string) { + return { + sessionType, + provider, + threadId, + revision: 1, + status: 'idle', + capabilities: { + send: true, + interrupt: false, + approvals: true, + questions: true, + fork: false, + }, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }, + pendingApprovals: [], + pendingQuestions: [], + worktrees: [], + diffs: [], + childThreads: [], + turns: [], + extensions: {}, + } +} + +describe('FreshAgentRuntimeManager', () => { + it('routes freshAgent.create through the adapter selected by sessionType', async () => { + const codexAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'codex-session-1' }), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + const created = await manager.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/workspace', + }) + + expect(codexAdapter.create).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'freshcodex', + cwd: '/workspace', + })) + expect(created).toEqual({ + sessionId: 'codex-session-1', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }) + }) + + it('routes creates with resumeSessionId through adapter.resume when available', async () => { + const codexAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'codex-session-created' }), + resume: vi.fn().mockResolvedValue({ sessionId: 'codex-session-resumed' }), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + const resumed = await manager.create({ + requestId: 'req-resume', + sessionType: 'freshcodex', + resumeSessionId: 'thread-existing-1', + }) + + expect(codexAdapter.resume).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'freshcodex', + resumeSessionId: 'thread-existing-1', + })) + expect(codexAdapter.create).not.toHaveBeenCalled() + expect(resumed).toEqual({ + sessionId: 'codex-session-resumed', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }) + }) + + it('routes freshAgent.kill through the tracked adapter and removes the session', async () => { + const claudeAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'claude-session-1' }), + kill: vi.fn().mockResolvedValue(true), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + adapter: claudeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ + requestId: 'req-kill', + sessionType: 'freshclaude', + }) + + await expect(manager.kill({ + sessionId: 'claude-session-1', + sessionType: 'freshclaude', + provider: 'claude', + })).resolves.toBe(true) + expect(claudeAdapter.kill).toHaveBeenCalledWith('claude-session-1') + await expect(manager.kill({ + sessionId: 'claude-session-1', + sessionType: 'freshclaude', + provider: 'claude', + })).rejects.toThrow(/not tracked/i) + }) + + it('keeps session-type registration separate when hidden sessions share one runtime adapter', async () => { + const claudeAdapter = { + create: vi.fn() + .mockResolvedValueOnce({ sessionId: 'freshclaude-session-1' }) + .mockResolvedValueOnce({ sessionId: 'kilroy-session-1' }), + getSnapshot: vi.fn().mockResolvedValue(makeSnapshot('freshclaude', 'claude', 'freshclaude-session-1')), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + adapter: claudeAdapter as any, + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + adapter: claudeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ requestId: 'req-1', sessionType: 'freshclaude' }) + await manager.create({ requestId: 'req-2', sessionType: 'kilroy' }) + await manager.getSnapshot({ + sessionType: 'freshclaude', + provider: 'claude', + threadId: 'freshclaude-session-1', + }) + + expect(claudeAdapter.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ sessionType: 'freshclaude' })) + expect(claudeAdapter.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ sessionType: 'kilroy' })) + expect(claudeAdapter.getSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ sessionType: 'freshclaude', provider: 'claude' }), + undefined, + ) + }) + + it('rejects a route locator whose sessionType and provider disagree', async () => { + const codexAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'codex-session-1' }), + getSnapshot: vi.fn(), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await expect(manager.getSnapshot({ + sessionType: 'freshcodex', + provider: 'claude', + threadId: 'codex-session-1', + })).rejects.toThrow('uses codex, not claude') + expect(codexAdapter.getSnapshot).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/server/mcp/freshell-tool.test.ts b/test/unit/server/mcp/freshell-tool.test.ts index d989ed28f..4d9b97bd7 100644 --- a/test/unit/server/mcp/freshell-tool.test.ts +++ b/test/unit/server/mcp/freshell-tool.test.ts @@ -38,7 +38,7 @@ describe('TOOL_DESCRIPTION and INSTRUCTIONS', () => { expect(INSTRUCTIONS).toContain('terminal') expect(INSTRUCTIONS).toContain('editor') expect(INSTRUCTIONS).toContain('browser') - expect(INSTRUCTIONS).toContain('agent-chat') + expect(INSTRUCTIONS).toContain('fresh-agent') expect(INSTRUCTIONS).toContain('picker') // Picker warning expect(INSTRUCTIONS).toContain('Picker panes are ephemeral') @@ -102,6 +102,44 @@ describe('executeAction -- tab actions', () => { expect(mockClient.post.mock.calls.at(-1)?.[1]).not.toHaveProperty('resumeSessionId') }) + it('new-tab rejects raw Codex resume ids', async () => { + mockClient.post.mockResolvedValue({ id: 't1' }) + + const result = await executeAction('new-tab', { + name: 'Codex', + mode: 'codex', + resume: 'thread-pre-durable', + }) + + expect(result).toEqual({ + error: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + hint: 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.', + }) + expect(mockClient.post).not.toHaveBeenCalled() + }) + + it('new-tab passes explicit canonical Codex sessionRef', async () => { + mockClient.post.mockResolvedValue({ id: 't1' }) + + await executeAction('new-tab', { + name: 'Codex', + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + }) + + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + name: 'Codex', + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + })) + }) + it('list-tabs calls GET /api/tabs', async () => { mockClient.get.mockResolvedValue({ tabs: [] }) await executeAction('list-tabs') @@ -166,6 +204,55 @@ describe('executeAction -- pane actions', () => { ) }) + it('split-pane passes explicit canonical Codex sessionRef', async () => { + mockClient.get.mockImplementation((path: string) => { + if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) + if (path.includes('/api/panes')) return Promise.resolve({ panes: [{ id: 'p1', index: 0, kind: 'terminal', terminalId: 'term-1' }] }) + return Promise.resolve({}) + }) + mockClient.post.mockResolvedValue({ ok: true }) + + await executeAction('split-pane', { + target: 'p1', + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + }) + + expect(mockClient.post).toHaveBeenCalledWith( + expect.stringContaining('/api/panes/p1/split'), + expect.objectContaining({ + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + }), + ) + }) + + it('split-pane rejects raw Codex resume ids', async () => { + mockClient.get.mockImplementation((path: string) => { + if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) + if (path.includes('/api/panes')) return Promise.resolve({ panes: [{ id: 'p1', index: 0, kind: 'terminal', terminalId: 'term-1' }] }) + return Promise.resolve({}) + }) + + const result = await executeAction('split-pane', { + target: 'p1', + mode: 'codex', + resume: 'thread-pre-durable', + }) + + expect(result).toEqual({ + error: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + hint: 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.', + }) + expect(mockClient.post).not.toHaveBeenCalled() + }) + it('list-panes calls GET /api/panes', async () => { mockClient.get.mockResolvedValue({ panes: [] }) await executeAction('list-panes') @@ -244,6 +331,46 @@ describe('executeAction -- pane actions', () => { await executeAction('respawn-pane', { target: 'p1' }) expect(mockClient.post).toHaveBeenCalledWith(expect.stringContaining('/api/panes/p1/respawn'), expect.anything()) }) + + it('respawn-pane passes explicit canonical Codex sessionRef', async () => { + mockClient.post.mockResolvedValue({ ok: true }) + + await executeAction('respawn-pane', { + target: 'p1', + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + }) + + expect(mockClient.post).toHaveBeenCalledWith( + expect.stringContaining('/api/panes/p1/respawn'), + expect.objectContaining({ + mode: 'codex', + sessionRef: { + provider: 'codex', + sessionId: '019e180a-9e92-7b63-9189-edaec526ad1a', + }, + }), + ) + }) + + it('respawn-pane rejects raw Codex resume ids', async () => { + mockClient.post.mockResolvedValue({ ok: true }) + + const result = await executeAction('respawn-pane', { + target: 'p1', + mode: 'codex', + resume: 'thread-pre-durable', + }) + + expect(result).toEqual({ + error: 'Restore requires sessionRef; resumeSessionId is a legacy field and cannot be used as restore identity.', + hint: 'Use sessionRef: { provider: "codex", sessionId } after Codex identity is durable.', + }) + expect(mockClient.post).not.toHaveBeenCalled() + }) }) describe('executeAction -- terminal I/O', () => { @@ -982,6 +1109,25 @@ describe('executeAction -- new-tab with prompt sends keys', () => { ) }) + it('new-tab with a Codex prompt asks the server to wait for Codex identity capture', async () => { + mockClient.post.mockImplementation((path: string) => { + if (path === '/api/tabs') { + return Promise.resolve({ status: 'ok', data: { id: 't1', paneId: 'p-new' } }) + } + return Promise.resolve({ ok: true }) + }) + + await executeAction('new-tab', { name: 'Work', mode: 'codex', prompt: 'build the thing' }) + + expect(mockClient.post).toHaveBeenCalledWith( + '/api/panes/p-new/send-keys', + expect.objectContaining({ + data: 'build the thing\r', + waitForCodexIdentity: true, + }), + ) + }) + it('new-tab without prompt does not send keys', async () => { mockClient.post.mockResolvedValue({ status: 'ok', data: { id: 't1', paneId: 'p-new' } }) await executeAction('new-tab', { name: 'Work', mode: 'claude' }) diff --git a/test/unit/server/production-edge-cases.test.ts b/test/unit/server/production-edge-cases.test.ts index d75417d98..b6be14370 100644 --- a/test/unit/server/production-edge-cases.test.ts +++ b/test/unit/server/production-edge-cases.test.ts @@ -509,7 +509,7 @@ describe('TerminalRegistry Production Edge Cases', () => { registry = new TerminalRegistry() const result = registry.input('nonexistent-terminal-id', 'test') - expect(result).toBe(false) + expect(result).toEqual({ status: 'no_terminal' }) }) it('handles input to exited terminal', () => { @@ -522,7 +522,7 @@ describe('TerminalRegistry Production Edge Cases', () => { emitExit(0) const result = registry.input(record.terminalId, 'test') - expect(result).toBe(false) + expect(result).toEqual({ status: 'not_running' }) }) it('handles resize with extreme dimensions', () => { diff --git a/test/unit/server/run-standard-tests.test.ts b/test/unit/server/run-standard-tests.test.ts index 971cdb670..bb9574b25 100644 --- a/test/unit/server/run-standard-tests.test.ts +++ b/test/unit/server/run-standard-tests.test.ts @@ -123,6 +123,21 @@ describe('run-standard-tests', () => { }) }) + it('routes real provider integration paths to the server suite only', () => { + expect(createStandardTestPlan({ + availableParallelism: 32, + ci: false, + forwardedArgs: ['test/integration/real/coding-cli-session-contract.test.ts'], + })).toEqual({ + mode: 'desktop', + stages: [ + [ + { name: 'server', configPath: 'vitest.server.config.ts', maxWorkers: '3', priority: 'background' }, + ], + ], + }) + }) + it('routes electron-targeted paths to the electron suite only', () => { expect(createStandardTestPlan({ availableParallelism: 32, diff --git a/test/unit/server/session-association-coordinator.test.ts b/test/unit/server/session-association-coordinator.test.ts index f2805c640..0bd9da473 100644 --- a/test/unit/server/session-association-coordinator.test.ts +++ b/test/unit/server/session-association-coordinator.test.ts @@ -91,7 +91,7 @@ describe('SessionAssociationCoordinator', () => { expect(registry.bindSession).toHaveBeenCalledWith('term-1', 'claude', 'session-main', 'association') }) - it('associates opencode sessions with matching unassociated terminals', () => { + it('does not attempt heuristic association for opencode sessions', () => { const registry = { findUnassociatedTerminals: vi.fn(() => [{ terminalId: 'term-2', createdAt: 1_000 }]), bindSession: vi.fn(() => ({ ok: true, terminalId: 'term-2', sessionId: 'session-main' })), @@ -101,9 +101,9 @@ describe('SessionAssociationCoordinator', () => { const result = coordinator.associateSingleSession(createSession({ provider: 'opencode' })) - expect(result).toEqual({ associated: true, terminalId: 'term-2' }) - expect(registry.findUnassociatedTerminals).toHaveBeenCalledWith('opencode', '/repo/project') - expect(registry.bindSession).toHaveBeenCalledWith('term-2', 'opencode', 'session-main', 'association') + expect(result).toEqual({ associated: false, reason: 'provider_managed' }) + expect(registry.findUnassociatedTerminals).not.toHaveBeenCalled() + expect(registry.bindSession).not.toHaveBeenCalled() }) it('skips association when session is already bound to another terminal', () => { diff --git a/test/unit/server/session-directory/fresh-agent-projection.test.ts b/test/unit/server/session-directory/fresh-agent-projection.test.ts new file mode 100644 index 000000000..3192db6ef --- /dev/null +++ b/test/unit/server/session-directory/fresh-agent-projection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { buildSessionDirectoryComparableSnapshot } from '../../../../server/session-directory/projection.js' + +describe('fresh-agent session-directory projection', () => { + it('projects fresh sessionType and codex runtime metadata through the indexed session directory snapshot', () => { + const snapshot = buildSessionDirectoryComparableSnapshot([ + { + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'sess-1', + projectPath: '/repo', + checkoutPath: '/repo/.worktrees/task-1', + lastActivityAt: 10, + title: 'Codex task', + summary: 'Summary', + sessionType: 'freshcodex', + isSubagent: true, + codexTaskEvents: { + latestTaskStartedAt: 1, + }, + }], + }, + ]) + + expect(snapshot[0]).toMatchObject({ + provider: 'codex', + sessionId: 'sess-1', + checkoutPath: '/repo/.worktrees/task-1', + sessionType: 'freshcodex', + isSubagent: true, + }) + }) +}) diff --git a/test/unit/server/session-directory/service.test.ts b/test/unit/server/session-directory/service.test.ts index 1e88c39c6..a4f81028a 100644 --- a/test/unit/server/session-directory/service.test.ts +++ b/test/unit/server/session-directory/service.test.ts @@ -313,6 +313,160 @@ describe('querySessionDirectory', () => { })).rejects.toThrow(/invalid session-directory cursor/i) }) + it('keeps running titleless sessions when empty sessions are hidden', async () => { + const page = await querySessionDirectory({ + projects: [ + makeProject('/repo/live', [ + makeSession({ + provider: 'opencode', + sessionId: 'ses_running_titleless', + projectPath: '/repo/live', + lastActivityAt: 1_800, + title: '', + }), + ]), + ], + terminalMeta: [ + makeTerminalMeta({ + terminalId: 'term-opencode-1', + provider: 'opencode', + sessionId: 'ses_running_titleless', + updatedAt: 1_900, + }), + ], + query: { + priority: 'visible', + includeEmpty: false, + includeSubagents: true, + includeNonInteractive: true, + limit: 50, + }, + }) + + const item = page.items.find((candidate) => candidate.sessionId === 'ses_running_titleless') + expect(item).toMatchObject({ + sessionId: 'ses_running_titleless', + isRunning: true, + runningTerminalId: 'term-opencode-1', + }) + }) + + it('keeps running whitespace-title sessions when empty sessions are hidden', async () => { + const page = await querySessionDirectory({ + projects: [ + makeProject('/repo/live', [ + makeSession({ + provider: 'opencode', + sessionId: 'ses_running_whitespace', + projectPath: '/repo/live', + lastActivityAt: 1_800, + title: ' ', + }), + makeSession({ + provider: 'opencode', + sessionId: 'ses_idle_whitespace', + projectPath: '/repo/live', + lastActivityAt: 1_700, + title: ' ', + }), + ]), + ], + terminalMeta: [ + makeTerminalMeta({ + terminalId: 'term-opencode-1', + provider: 'opencode', + sessionId: 'ses_running_whitespace', + updatedAt: 1_900, + }), + ], + query: { + priority: 'visible', + includeEmpty: false, + includeSubagents: true, + includeNonInteractive: true, + limit: 50, + }, + }) + + expect(page.items.map((item) => item.sessionId)).toEqual(['ses_running_whitespace']) + expect(page.items[0]).toMatchObject({ + isRunning: true, + runningTerminalId: 'term-opencode-1', + }) + }) + + it('exposes running coding terminals before a provider session id is known', async () => { + const page = await querySessionDirectory({ + projects: [], + terminalMeta: [ + makeTerminalMeta({ + terminalId: 'term-opencode-live', + provider: 'opencode', + cwd: '/repo/live', + checkoutRoot: '/repo/live', + updatedAt: 1_900, + }), + ], + query: { + priority: 'visible', + includeEmpty: false, + includeSubagents: true, + includeNonInteractive: true, + limit: 50, + }, + }) + + expect(page.items).toEqual([ + expect.objectContaining({ + provider: 'opencode', + sessionId: 'terminal:term-opencode-live', + projectPath: '/repo/live', + checkoutPath: '/repo/live', + title: 'OpenCode', + cwd: '/repo/live', + sessionType: 'opencode', + isRunning: true, + runningTerminalId: 'term-opencode-live', + liveTerminalOnly: true, + }), + ]) + expect(page.revision).toBe(1_900) + }) + + it('exposes running session ids that are not indexed yet', async () => { + const page = await querySessionDirectory({ + projects: [], + terminalMeta: [ + makeTerminalMeta({ + terminalId: 'term-codex-live', + provider: 'codex', + sessionId: 'codex-session-new', + cwd: '/repo/live', + updatedAt: 1_900, + }), + ], + query: { + priority: 'visible', + includeEmpty: false, + includeSubagents: true, + includeNonInteractive: true, + limit: 50, + }, + }) + + expect(page.items).toEqual([ + expect.objectContaining({ + provider: 'codex', + sessionId: 'codex-session-new', + projectPath: '/repo/live', + title: 'Codex CLI', + isRunning: true, + runningTerminalId: 'term-codex-live', + liveTerminalOnly: false, + }), + ]) + }) + it('caps page size at 50 even when a larger limit is requested', async () => { const manyProjects: ProjectGroup[] = [ makeProject('/repo/many', Array.from({ length: 75 }, (_, index) => makeSession({ diff --git a/test/unit/server/tabs-registry/store.test.ts b/test/unit/server/tabs-registry/store.test.ts index ff80c17f1..f88e09e3e 100644 --- a/test/unit/server/tabs-registry/store.test.ts +++ b/test/unit/server/tabs-registry/store.test.ts @@ -9,6 +9,8 @@ import { import type { RegistryTabRecord } from '../../../../server/tabs-registry/types.js' const NOW = 1_740_000_000_000 +const MINUTE_MS = 60 * 1000 +const DAY_MS = 24 * 60 * 60 * 1000 function makeRecord(overrides: Partial<RegistryTabRecord>): RegistryTabRecord { return { @@ -29,99 +31,905 @@ function makeRecord(overrides: Partial<RegistryTabRecord>): RegistryTabRecord { } } -describe('TabsRegistryStore', () => { +async function replace( + store: TabsRegistryStore, + input: { + deviceId: string + deviceLabel?: string + clientInstanceId: string + snapshotRevision: number + records: RegistryTabRecord[] + }, +) { + return store.replaceClientSnapshot({ + deviceId: input.deviceId, + deviceLabel: input.deviceLabel ?? input.deviceId, + clientInstanceId: input.clientInstanceId, + snapshotRevision: input.snapshotRevision, + records: input.records, + }) +} + +describe('TabsRegistryStore compact state', () => { let tempDir: string + let now = NOW let store: TabsRegistryStore beforeEach(async () => { + now = NOW tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tabs-registry-store-')) - store = createTabsRegistryStore(tempDir, { now: () => NOW }) + store = await createTabsRegistryStore(tempDir, { now: () => now }) }) afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }) }) - it('returns only live + closed within 24h for default snapshot', async () => { - const recordOpen = makeRecord({ - tabKey: 'local:open-1', - tabId: 'open-1', + it('scopes open replacement to one client instance and splits same-device open tabs', async () => { + await replace(store, { deviceId: 'local-device', - status: 'open', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:a', tabId: 'a', deviceId: 'local-device', deviceLabel: 'local', tabName: 'A' }), + ], }) - const recordClosedRecent = makeRecord({ - tabKey: 'remote:closed-recent', - tabId: 'closed-recent', - deviceId: 'remote-device', - status: 'closed', - closedAt: NOW - 2 * 60 * 60 * 1000, - updatedAt: NOW - 2 * 60 * 60 * 1000, + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:b', tabId: 'b', deviceId: 'local-device', deviceLabel: 'local', tabName: 'B' }), + ], }) - const recordClosedOld = makeRecord({ - tabKey: 'remote:closed-old', - tabId: 'closed-old', + await replace(store, { deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'remote:r', tabId: 'r', deviceId: 'remote-device', deviceLabel: 'remote', tabName: 'R' }), + ], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [ + makeRecord({ tabKey: 'local:a2', tabId: 'a2', deviceId: 'local-device', deviceLabel: 'local', tabName: 'A2' }), + ], + }) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen.map((record) => record.tabKey)).toEqual(['local:a2']) + expect(result.sameDeviceOpen.map((record) => record.tabKey)).toEqual(['local:b']) + expect(result.remoteOpen.map((record) => record.tabKey)).toEqual(['remote:r']) + }) + + it('rejects stale revisions but accepts same-revision idempotent retries only with matching content', async () => { + const record = makeRecord({ tabKey: 'local:a', tabId: 'a', deviceId: 'local-device', deviceLabel: 'local' }) + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + })).resolves.toMatchObject({ accepted: true, openRecords: 1, closedRecords: 0 }) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [record], + })).rejects.toThrow(/stale snapshot revision/i) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + })).resolves.toMatchObject({ accepted: true, openRecords: 1, closedRecords: 0 }) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [{ ...record, tabName: 'different' }], + })).rejects.toThrow(/duplicate snapshot revision/i) + }) + + it('rejects same-revision retries whose closed tombstones differ from the committed push', async () => { + const open = makeRecord({ tabKey: 'local:open', tabId: 'open', deviceId: 'local-device', deviceLabel: 'local' }) + const closed = makeRecord({ + tabKey: 'local:closed', + tabId: 'closed', + deviceId: 'local-device', + deviceLabel: 'local', status: 'closed', - closedAt: NOW - 3 * 24 * 60 * 60 * 1000, - updatedAt: NOW - 3 * 24 * 60 * 60 * 1000, + updatedAt: NOW, + closedAt: NOW, + }) + + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [open], }) - await store.upsert(recordOpen) - await store.upsert(recordClosedRecent) - await store.upsert(recordClosedOld) + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [open, closed], + })).rejects.toThrow(/duplicate snapshot revision/i) - const result = await store.query({ deviceId: 'local-device' }) - expect(result.localOpen.some((record) => record.tabKey === recordOpen.tabKey)).toBe(true) - expect(result.closed.some((record) => record.tabKey === recordClosedRecent.tabKey)).toBe(true) - expect(result.closed.some((record) => record.tabKey === recordClosedOld.tabKey)).toBe(false) + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.closed).toHaveLength(0) }) - it('groups remote open tabs separately', async () => { - await store.upsert(makeRecord({ - tabKey: 'local:open-1', - tabId: 'open-1', + it('retire removes only the matching client snapshot and ignores stale retires', async () => { + await replace(store, { deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 3, + records: [ + makeRecord({ tabKey: 'local:a', tabId: 'a', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:b', tabId: 'b', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + + await expect(store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 2, + })).resolves.toEqual({ accepted: false }) + await expect(store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 4, + })).resolves.toEqual({ accepted: true }) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-b', + closedTabRetentionDays: 30, + }) + expect(result.localOpen.map((record) => record.tabKey)).toEqual(['local:b']) + expect(result.sameDeviceOpen).toHaveLength(0) + }) + + it('does not let an equal-revision old retire delete a newer reload snapshot', async () => { + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 5, + records: [ + makeRecord({ tabKey: 'local:old', tabId: 'old', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 6, + records: [ + makeRecord({ tabKey: 'local:new', tabId: 'new', deviceId: 'local-device', deviceLabel: 'local' }), + ], + }) + + await expect(store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 6, + })).resolves.toEqual({ accepted: false }) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen.map((record) => record.tabKey)).toEqual(['local:new']) + }) + + it('does not let a late stale push recreate a client snapshot after a newer retire', async () => { + const record = makeRecord({ tabKey: 'local:rev2', tabId: 'rev2', deviceId: 'local-device', deviceLabel: 'local' }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + }) + + await expect(store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 3, + })).resolves.toEqual({ accepted: true }) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + })).rejects.toThrow(/stale snapshot revision/i) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen).toHaveLength(0) + }) + + it('does not let a no-current retire lose its revision watermark before delayed stale pushes', async () => { + const record = makeRecord({ tabKey: 'local:rev2', tabId: 'rev2', deviceId: 'local-device', deviceLabel: 'local' }) + await expect(store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 3, + })).resolves.toEqual({ accepted: true }) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + })).rejects.toThrow(/stale snapshot revision/i) + }) + + it('keeps retired revision watermarks past the open snapshot TTL so stale pushes stay rejected', async () => { + const record = makeRecord({ tabKey: 'local:rev2', tabId: 'rev2', deviceId: 'local-device', deviceLabel: 'local' }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + }) + await store.retireClientSnapshot({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + snapshotRevision: 3, + }) + + now = NOW + 31 * MINUTE_MS + await replace(store, { + deviceId: 'other-device', + deviceLabel: 'other', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [], + }) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [record], + })).rejects.toThrow(/stale snapshot revision/i) + }) + + it('does not count retired revision watermarks against active client snapshot refs', async () => { + const capped = await createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxClientSnapshotRefs: 2 }, + }) + for (let i = 0; i < 2; i += 1) { + await replace(capped, { + deviceId: `retired-${i}`, + deviceLabel: `Retired ${i}`, + clientInstanceId: 'window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: `retired-${i}:tab`, + tabId: `retired-${i}`, + deviceId: `retired-${i}`, + deviceLabel: `Retired ${i}`, + }), + ], + }) + await capped.retireClientSnapshot({ + deviceId: `retired-${i}`, + clientInstanceId: 'window', + snapshotRevision: 2, + }) + } + + await expect(replace(capped, { + deviceId: 'live-device', + deviceLabel: 'Live', + clientInstanceId: 'window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'live-device:tab', + tabId: 'live', + deviceId: 'live-device', + deviceLabel: 'Live', + }), + ], + })).resolves.toMatchObject({ accepted: true, openRecords: 1 }) + }) + + it('rejects fresh client snapshots beyond the snapshot ref cap instead of truncating live state', async () => { + const capped = await createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxClientSnapshotRefs: 2 }, + }) + for (let i = 0; i < 2; i += 1) { + now += 1 + await replace(capped, { + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + clientInstanceId: 'window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: `device-${i}:tab`, + tabId: `tab-${i}`, + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + }), + ], + }) + } + + await expect(replace(capped, { + deviceId: 'device-2', + deviceLabel: 'Device 2', + clientInstanceId: 'window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'device-2:tab', + tabId: 'tab-2', + deviceId: 'device-2', + deviceLabel: 'Device 2', + }), + ], + })).rejects.toThrow(/client snapshots/i) + + const result = await capped.query({ + deviceId: 'device-0', + clientInstanceId: 'window', + closedTabRetentionDays: 30, + }) + expect(result.localOpen.map((record) => record.tabKey)).toEqual(['device-0:tab']) + expect(result.remoteOpen.map((record) => record.tabKey)).toEqual(['device-1:tab']) + }) + + it('uses safe snapshot keys so device and client ids cannot collide', async () => { + await replace(store, { + deviceId: 'a:b', + deviceLabel: 'First', + clientInstanceId: 'c', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'first', tabId: 'first', deviceId: 'a:b', deviceLabel: 'First' }), + ], + }) + await replace(store, { + deviceId: 'a', + deviceLabel: 'Second', + clientInstanceId: 'b:c', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'second', tabId: 'second', deviceId: 'a', deviceLabel: 'Second' }), + ], + }) + + const first = await store.query({ deviceId: 'a:b', clientInstanceId: 'c', closedTabRetentionDays: 30 }) + const second = await store.query({ deviceId: 'a', clientInstanceId: 'b:c', closedTabRetentionDays: 30 }) + expect(first.localOpen.map((record) => record.tabKey)).toEqual(['first']) + expect(second.localOpen.map((record) => record.tabKey)).toEqual(['second']) + expect(store.count()).toBe(2) + }) + + it('resolves same-event open ties deterministically using client source metadata', async () => { + const makeTie = (clientInstanceId: string) => makeRecord({ + tabKey: 'local:tie', + tabId: 'tie', + deviceId: 'local-device', + deviceLabel: 'local', + tabName: clientInstanceId, + revision: 1, + updatedAt: NOW, + }) + + async function run(order: string[]) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tabs-registry-tie-')) + const tieStore = await createTabsRegistryStore(dir, { now: () => now }) + try { + for (const clientInstanceId of order) { + await replace(tieStore, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId, + snapshotRevision: 1, + records: [makeTie(clientInstanceId)], + }) + } + const result = await tieStore.query({ + deviceId: 'other-device', + clientInstanceId: 'other-window', + closedTabRetentionDays: 30, + }) + return result.remoteOpen.map((record) => record.tabName) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + } + + await expect(run(['a', 'b'])).resolves.toEqual(await run(['b', 'a'])) + }) + + it('keeps closed tombstones across later omissions and uses updatedAt before revision for LWW', async () => { + const staleOpen = makeRecord({ + tabKey: 'local:a', + tabId: 'a', + deviceId: 'local-device', + deviceLabel: 'local', status: 'open', - })) - await store.upsert(makeRecord({ - tabKey: 'remote:open-1', - tabId: 'open-2', - deviceId: 'remote-device', - status: 'open', - })) + revision: 50, + updatedAt: NOW - 10_000, + }) + const newerClosedLowerRevision = makeRecord({ + ...staleOpen, + status: 'closed', + revision: 1, + updatedAt: NOW - 1_000, + closedAt: NOW - 1_000, + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [staleOpen], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [newerClosedLowerRevision], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [], + }) - const result = await store.query({ deviceId: 'local-device' }) - expect(result.localOpen).toHaveLength(1) - expect(result.remoteOpen).toHaveLength(1) - expect(result.remoteOpen[0]?.deviceId).toBe('remote-device') + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen).toHaveLength(0) + expect(result.closed.map((record) => record.tabKey)).toEqual(['local:a']) }) - it('uses last-write-wins by revision and updatedAt', async () => { - const base = makeRecord({ - tabKey: 'local:open-1', + it('chooses closed over open when open and closed records tie on updatedAt and revision', async () => { + const open = makeRecord({ + tabKey: 'local:exact-tie', + tabId: 'open-tie', deviceId: 'local-device', - tabName: 'older', - revision: 2, - updatedAt: NOW - 4_000, + deviceLabel: 'local', + status: 'open', + revision: 4, + updatedAt: NOW, + }) + const closed = makeRecord({ + ...open, + tabId: 'closed-tie', + status: 'closed', + closedAt: NOW, + }) + + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-open', + snapshotRevision: 1, + records: [open], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-closed', + snapshotRevision: 1, + records: [closed], }) - const stale = makeRecord({ - ...base, - tabName: 'stale', + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-open', + closedTabRetentionDays: 30, + }) + expect(result.localOpen).toHaveLength(0) + expect(result.sameDeviceOpen).toHaveLength(0) + expect(result.closed.map((record) => record.tabKey)).toEqual(['local:exact-tie']) + }) + + it('lets a newer open delete an older closed tombstone so it cannot return after TTL or restart', async () => { + const closed = makeRecord({ + tabKey: 'local:a', + tabId: 'a', + deviceId: 'local-device', + deviceLabel: 'local', + status: 'closed', revision: 1, + updatedAt: NOW - 10_000, + closedAt: NOW - 10_000, + }) + const reopened = makeRecord({ + ...closed, + status: 'open', + revision: 2, updatedAt: NOW - 1_000, + closedAt: undefined, + }) + + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [closed], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 2, + records: [reopened], + }) + + now = NOW + 31 * MINUTE_MS + const restarted = await createTabsRegistryStore(tempDir, { now: () => now }) + const result = await restarted.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, }) - const newer = makeRecord({ - ...base, - tabName: 'newer', + expect(result.localOpen).toHaveLength(0) + expect(result.closed).toHaveLength(0) + }) + + it('does not retain an older closed tombstone when a newer open winner already exists', async () => { + const newerOpen = makeRecord({ + tabKey: 'local:a', + tabId: 'a', + deviceId: 'local-device', + deviceLabel: 'local', + status: 'open', revision: 2, + updatedAt: NOW - 1_000, + }) + const staleClosed = makeRecord({ + ...newerOpen, + status: 'closed', + revision: 1, + updatedAt: NOW - 10_000, + closedAt: NOW - 10_000, + }) + + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [newerOpen], + }) + await replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [staleClosed], + }) + + now = NOW + 31 * MINUTE_MS + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.localOpen).toHaveLength(0) + expect(result.closed).toHaveLength(0) + }) + + it('uses retained closed winners for conflict resolution before requested retention filtering', async () => { + const oldOpen = makeRecord({ + tabKey: 'remote:a', + tabId: 'a', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'open', + revision: 3, + updatedAt: NOW - 12 * DAY_MS, + }) + const closedTenDaysAgo = makeRecord({ + ...oldOpen, + status: 'closed', + revision: 1, + updatedAt: NOW - 10 * DAY_MS, + closedAt: NOW - 10 * DAY_MS, + }) + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + records: [oldOpen], + }) + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-closer', + snapshotRevision: 1, + records: [closedTenDaysAgo], + }) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 7, + }) + expect(result.remoteOpen).toHaveLength(0) + expect(result.closed).toHaveLength(0) + }) + + it('does not let tombstones older than server retention suppress fresh opens during pure query', async () => { + const ancientClosed = makeRecord({ + tabKey: 'remote:a', + tabId: 'a', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'closed', + revision: 5, + updatedAt: NOW + 1_000, + closedAt: NOW - 31 * DAY_MS, + }) + const freshOpen = makeRecord({ + ...ancientClosed, + status: 'open', + revision: 1, updatedAt: NOW, + closedAt: undefined, + }) + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ancientClosed], + }) + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'window-b', + snapshotRevision: 1, + records: [freshOpen], + }) + + const result = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(result.remoteOpen.map((record) => record.tabKey)).toEqual(['remote:a']) + expect(result.closed).toHaveLength(0) + }) + + it('resolves same-event closed ties deterministically using client source metadata', async () => { + const makeClosedTie = (clientInstanceId: string) => makeRecord({ + tabKey: 'local:closed-tie', + tabId: 'closed-tie', + deviceId: 'local-device', + deviceLabel: 'local', + tabName: clientInstanceId, + status: 'closed', + revision: 1, + updatedAt: NOW, + closedAt: NOW, + }) + + async function run(order: string[]) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tabs-registry-closed-tie-')) + const tieStore = await createTabsRegistryStore(dir, { now: () => now }) + try { + for (const clientInstanceId of order) { + await replace(tieStore, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId, + snapshotRevision: 1, + records: [makeClosedTie(clientInstanceId)], + }) + } + const result = await tieStore.query({ + deviceId: 'other-device', + clientInstanceId: 'other-window', + closedTabRetentionDays: 30, + }) + return result.closed.map((record) => ({ + tabName: record.tabName, + clientInstanceId: record.clientInstanceId, + })) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + } + + await expect(run(['a', 'b'])).resolves.toEqual(await run(['b', 'a'])) + }) + + it('uses server receipt time for open snapshot freshness and keeps devices for seven days', async () => { + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'remote:a', + tabId: 'a', + deviceId: 'remote-device', + deviceLabel: 'remote', + updatedAt: NOW - 30 * DAY_MS, + }), + ], }) - await store.upsert(base) - await store.upsert(stale) - await store.upsert(newer) + now = NOW + 31 * MINUTE_MS + const afterOpenTtl = await store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 30, + }) + expect(afterOpenTtl.remoteOpen).toHaveLength(0) + expect(store.listDevices().map((device) => device.deviceId)).toContain('remote-device') - const result = await store.query({ deviceId: 'local-device' }) - expect(result.localOpen[0]?.tabName).toBe('newer') + now = NOW + 8 * DAY_MS + expect(store.listDevices().map((device) => device.deviceId)).not.toContain('remote-device') + }) + + it('bounds recent device metadata by count during maintenance', async () => { + const capped = await createTabsRegistryStore(tempDir, { + now: () => now, + caps: { maxDevices: 2 }, + }) + for (let i = 0; i < 3; i += 1) { + now += 1 + await replace(capped, { + deviceId: `device-${i}`, + deviceLabel: `Device ${i}`, + clientInstanceId: 'window', + snapshotRevision: 1, + records: [], + }) + await capped.retireClientSnapshot({ + deviceId: `device-${i}`, + clientInstanceId: 'window', + snapshotRevision: 2, + }) + } + + expect(capped.listDevices().map((device) => device.deviceId)).toEqual(['device-2', 'device-1']) + }) + + it('does not create device rows from closed tombstones alone', async () => { + await replace(store, { + deviceId: 'remote-device', + deviceLabel: 'remote', + clientInstanceId: 'remote-window', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'remote:closed', + tabId: 'closed', + deviceId: 'remote-device', + deviceLabel: 'remote', + status: 'closed', + updatedAt: NOW - 1_000, + closedAt: NOW - 1_000, + }), + ], + }) + await store.retireClientSnapshot({ + deviceId: 'remote-device', + clientInstanceId: 'remote-window', + snapshotRevision: 2, + }) + + expect(store.listDevices().map((device) => device.deviceId)).toContain('remote-device') + now = NOW + 8 * DAY_MS + expect(store.listDevices().map((device) => device.deviceId)).not.toContain('remote-device') + }) + + it('rejects invalid retention, oversized pushes, oversized panes, and duplicate tab keys clearly', async () => { + await expect(store.query({ + deviceId: 'local-device', + clientInstanceId: 'window-a', + closedTabRetentionDays: 31, + })).rejects.toThrow(/closed tab retention.*1.*30/i) + + const tooManyRecords = Array.from({ length: 501 }, (_, index) => makeRecord({ + tabKey: `local:${index}`, + tabId: `tab-${index}`, + deviceId: 'local-device', + deviceLabel: 'local', + })) + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: tooManyRecords, + })).rejects.toThrow(/at most 500 records/i) + + const largePayload = 'x'.repeat(1024 * 1024) + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ + tabKey: 'local:huge', + tabId: 'huge', + deviceId: 'local-device', + deviceLabel: 'local', + paneCount: 1, + panes: [{ paneId: 'pane-1', kind: 'terminal', payload: { largePayload } }], + }), + ], + })).rejects.toThrow(/push payload.*1 mib|client snapshot.*512 kib/i) + + await expect(replace(store, { + deviceId: 'local-device', + deviceLabel: 'local', + clientInstanceId: 'window-a', + snapshotRevision: 1, + records: [ + makeRecord({ tabKey: 'local:dup', tabId: 'a', deviceId: 'local-device', deviceLabel: 'local' }), + makeRecord({ tabKey: 'local:dup', tabId: 'b', deviceId: 'local-device', deviceLabel: 'local' }), + ], + })).rejects.toThrow(/duplicate tab key/i) }) }) diff --git a/test/unit/server/terminal-lifecycle.test.ts b/test/unit/server/terminal-lifecycle.test.ts index 71db44d00..cd585caa6 100644 --- a/test/unit/server/terminal-lifecycle.test.ts +++ b/test/unit/server/terminal-lifecycle.test.ts @@ -664,7 +664,7 @@ describe('TerminalRegistry Lifecycle', () => { pty._emitExit(0) const result = registry.input(term.terminalId, 'some input') - expect(result).toBe(false) + expect(result).toEqual({ status: 'not_running' }) }) it('should not call pty.write on exited terminal', () => { @@ -679,7 +679,7 @@ describe('TerminalRegistry Lifecycle', () => { it('should return false for input to non-existent terminal', () => { const result = registry.input('non-existent-id', 'some input') - expect(result).toBe(false) + expect(result).toEqual({ status: 'no_terminal' }) }) it('should update lastActivityAt on successful input', () => { diff --git a/test/unit/server/terminal-registry.codex-recovery.test.ts b/test/unit/server/terminal-registry.codex-recovery.test.ts index c544d66dc..cbe72377e 100644 --- a/test/unit/server/terminal-registry.codex-recovery.test.ts +++ b/test/unit/server/terminal-registry.codex-recovery.test.ts @@ -1,8 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import WebSocket from 'ws' import { TerminalRegistry } from '../../../server/terminal-registry.js' -import { TerminalStreamBroker } from '../../../server/terminal-stream/broker.js' -import type { CodexThreadLifecycleEvent } from '../../../server/coding-cli/codex-app-server/client.js' +import type { CodexLaunchSidecar } from '../../../server/coding-cli/codex-app-server/launch-planner.js' type MockPty = { onData: ReturnType<typeof vi.fn> @@ -57,70 +55,32 @@ function createMockPty(): MockPty { } } -async function lastPty(): Promise<MockPty> { - const pty = await import('node-pty') - return vi.mocked(pty.spawn).mock.results.at(-1)?.value as MockPty -} - async function spawnedPtys(): Promise<MockPty[]> { const pty = await import('node-pty') return vi.mocked(pty.spawn).mock.results.map((result) => result.value as MockPty) } -async function loggerWarnCalls(): Promise<Array<[Record<string, any>, string]>> { - const { logger } = await import('../../../server/logger.js') - return vi.mocked(logger.warn).mock.calls as Array<[Record<string, any>, string]> -} - -function createMockWs(connectionId: string) { - return { - bufferedAmount: 0, - readyState: WebSocket.OPEN, - send: vi.fn(), - close: vi.fn(), - connectionId, - } -} - -function sentPayloads(ws: ReturnType<typeof createMockWs>) { - return ws.send.mock.calls - .map(([raw]) => (typeof raw === 'string' ? JSON.parse(raw) : raw)) - .filter((payload): payload is Record<string, any> => !!payload && typeof payload === 'object') -} - -type MockSidecarAttachment = { - terminalId: string - onDurableSession: (sessionId: string) => void - onThreadLifecycle: (event: CodexThreadLifecycleEvent) => void - onFatal: (error: Error, source?: 'sidecar_fatal' | 'app_server_exit' | 'app_server_client_disconnect') => void +function deferred<T = void>() { + let resolve!: (value: T | PromiseLike<T>) => void + let reject!: (reason?: unknown) => void + const promise = new Promise<T>((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } } -function createMockSidecar(options: { onAttach?: (attachment: MockSidecarAttachment) => void } = {}) { - let attachment: MockSidecarAttachment | undefined +function createFakeSidecar(options: { + shutdown?: CodexLaunchSidecar['shutdown'] +} = {}): CodexLaunchSidecar { return { - api: { - attachTerminal: vi.fn((next: MockSidecarAttachment) => { - attachment = next - options.onAttach?.(next) - }), - shutdown: vi.fn().mockResolvedValue(undefined), - }, - emitDurableSession(sessionId: string) { - attachment?.onDurableSession(sessionId) - }, - emitLifecycle(event: CodexThreadLifecycleEvent) { - attachment?.onThreadLifecycle(event) - }, - emitFatal( - error = new Error('fake sidecar fatal'), - source: 'sidecar_fatal' | 'app_server_exit' | 'app_server_client_disconnect' = 'sidecar_fatal', - ) { - attachment?.onFatal(error, source) - }, + adopt: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn(options.shutdown ?? (async () => undefined)), + onLifecycleLoss: vi.fn(() => vi.fn()), } } -describe('TerminalRegistry Codex recovery generation guards', () => { +describe('TerminalRegistry Codex durable recovery', () => { let registry: TerminalRegistry beforeEach(async () => { @@ -135,985 +95,151 @@ describe('TerminalRegistry Codex recovery generation guards', () => { vi.useRealTimers() }) - it('ignores stale generation PTY data and exit without mutating stable output or final state', async () => { - const record = registry.create({ mode: 'codex', cwd: '/repo' }) - const mockPty = await lastPty() - const onData = mockPty.onData.mock.calls[0][0] - const onExit = mockPty.onExit.mock.calls[0][0] - record.codex!.workerGeneration = 2 - - onData('stale output') - onExit({ exitCode: 9, signal: 0 }) - - expect(record.buffer.snapshot()).toBe('') - expect(record.status).toBe('running') - }) - - it('ignores recovery-retire generation output and exit', async () => { - const record = registry.create({ mode: 'codex', cwd: '/repo' }) - const mockPty = await lastPty() - const onData = mockPty.onData.mock.calls[0][0] - const onExit = mockPty.onExit.mock.calls[0][0] - record.codex!.retiringGenerations.add(1) - record.codex!.closeReasonByGeneration.set(1, 'recovery_retire') - - onData('retired output') - onExit({ exitCode: 9, signal: 0 }) - - expect(record.buffer.snapshot()).toBe('') - expect(record.status).toBe('running') - }) - - it('treats explicit user final close as final and emits terminal.exit', async () => { - const exited = vi.fn() - registry.on('terminal.exit', exited) - const record = registry.create({ mode: 'codex', cwd: '/repo' }) - - registry.kill(record.terminalId) - - expect(record.codex!.closeReasonByGeneration.get(1)).toBe('user_final_close') - expect(record.status).toBe('exited') - expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 0 }) - }) - - it('treats in-TUI PTY exit for a durable Codex session as recoverable, not final', async () => { - const exited = vi.fn() - registry.on('terminal.exit', exited) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - const mockPty = await lastPty() - const onExit = mockPty.onExit.mock.calls[0][0] - - onExit({ exitCode: 0, signal: 0 }) - - expect(record.status).toBe('running') - expect(record.codex!.recoveryState).toBe('recovering_durable') - expect(record.codex!.durableSessionId).toBe('thread-durable-1') - expect(exited).not.toHaveBeenCalled() - }) - - it('initializes durable Codex state from an explicit resume session id', () => { - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - - expect(record.codex?.durableSessionId).toBe('thread-durable-1') - expect(record.codex?.recoveryState).toBe('running_durable') - expect(record.resumeSessionId).toBe('thread-durable-1') - }) - - it('keeps non-Codex PTY exit final', async () => { + it('recovers a durable Codex terminal when the visible PTY exits unexpectedly', async () => { const exited = vi.fn() registry.on('terminal.exit', exited) - const record = registry.create({ mode: 'shell', cwd: '/repo' }) - const mockPty = await lastPty() - const onExit = mockPty.onExit.mock.calls[0][0] - - onExit({ exitCode: 3, signal: 0 }) - - expect(record.status).toBe('exited') - expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 3 }) - }) - - it('replaces a durable Codex worker bundle after PTY exit without finalizing the terminal', async () => { - const exited = vi.fn() - const status = vi.fn() - registry.on('terminal.exit', exited) - registry.on('terminal.status', status) - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ + const currentSidecar = createFakeSidecar() + const replacementSidecar = createFakeSidecar() + const planCreate = vi.fn(async () => ({ sessionId: 'thread-durable-1', remote: { wsUrl: 'ws://127.0.0.1:46002/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - providerSettings: { - codexAppServer: { wsUrl: 'ws://127.0.0.1:46001/' }, - model: 'codex-test', - }, - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - envContext: { tabId: 'tab-1', paneId: 'pane-1' }, - }) - const oldPty = await lastPty() - const onExit = oldPty.onExit.mock.calls[0][0] - - onExit({ exitCode: 0, signal: 0 }) - - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const allPtys = await spawnedPtys() - const replacementPty = allPtys.at(-1)! - const replacementSpawnArgs = (await import('node-pty')).spawn.mock.calls.at(-1)?.[1] as string[] - - expect(record.status).toBe('running') - expect(record.terminalId).toBeDefined() - expect(record.codex?.durableSessionId).toBe('thread-durable-1') - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(record.codex?.retiringGenerations.has(1)).toBe(true) - expect(initialSidecar.api.shutdown).toHaveBeenCalledTimes(1) - expect(oldPty.kill).toHaveBeenCalledTimes(1) - expect(record.pty).toBe(replacementPty) - expect(launchFactory).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - envContext: { tabId: 'tab-1', paneId: 'pane-1' }, - providerSettings: expect.objectContaining({ model: 'codex-test' }), - })) - expect(replacementSpawnArgs).toEqual(expect.arrayContaining([ - '--remote', - 'ws://127.0.0.1:46002/', - 'resume', - 'thread-durable-1', - ])) - expect(status).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - status: 'recovering', - attempt: 1, + sidecar: replacementSidecar, })) - expect(exited).not.toHaveBeenCalled() - }) - - it('coalesces duplicate current-generation failure signals into one replacement attempt', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46003/' }, - sidecar: replacementSidecar.api, - }) - registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - - initialSidecar.emitFatal() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - oldPty.onData.mock.calls[0][0]('late retired output') - - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(1)) - }) - - it('flushes recovery-buffered input only after current-generation durable readiness proof', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46004/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - expect(record.pty).toBe(replacementPty) - expect(record.codex?.recoveryState).toBe('recovering_durable') - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - replacementPty.onData.mock.calls[0][0]('process output before proof') - expect(oldPty.write).not.toHaveBeenCalledWith('abc') - expect(replacementPty.write).not.toHaveBeenCalledWith('abc') - - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-1', - path: '/tmp/rollout-thread-durable-1.jsonl', - ephemeral: false, - }, - }) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.write).toHaveBeenCalledWith('abc') - }) - - it('logs recovery transition context with websocket URLs and process identifiers when known', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46028/', processPid: 45678 }, - sidecar: replacementSidecar.api, - }) const record = registry.create({ mode: 'codex', cwd: '/repo', resumeSessionId: 'thread-durable-1', providerSettings: { - codexAppServer: { wsUrl: 'ws://127.0.0.1:46027/' }, - }, - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, + codexAppServer: { + wsUrl: 'ws://127.0.0.1:46001/', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, }) - const oldPty = await lastPty() + const [oldPty] = await spawnedPtys() oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-1', - path: '/tmp/rollout-thread-durable-1.jsonl', - ephemeral: false, - }, - }) - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - const warns = await loggerWarnCalls() - const started = warns.find(([, message]) => message === 'codex_recovery_started')?.[0] - const ready = warns.find(([, message]) => message === 'codex_recovery_ready')?.[0] + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: record.terminalId, generation: 1 })) + await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1)) + const [, replacementPty] = await spawnedPtys() - expect(started).toEqual(expect.objectContaining({ + expect(registry.get(record.terminalId)?.status).toBe('running') + expect(registry.get(record.terminalId)?.pty).toBe(replacementPty) + expect(planCreate).toHaveBeenCalledWith(expect.objectContaining({ terminalId: record.terminalId, - oldWsUrl: 'ws://127.0.0.1:46027/', - oldPtyPid: 12345, - source: 'pty_exit', + resumeSessionId: 'thread-durable-1', generation: 1, - candidateGeneration: 2, - attempt: 1, - hasDurableSession: true, - })) - expect(ready).toEqual(expect.objectContaining({ - terminalId: record.terminalId, - oldWsUrl: 'ws://127.0.0.1:46027/', - newWsUrl: 'ws://127.0.0.1:46028/', - oldPtyPid: 12345, - newPtyPid: 12345, - newAppServerPid: 45678, - generation: 2, - attempt: 1, - hasDurableSession: true, })) + expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: record.terminalId, generation: 1 }) + expect(oldPty.kill).toHaveBeenCalledTimes(1) + expect(exited).not.toHaveBeenCalled() }) - it('applies latest resize to durable replacement PTY before flushing buffered input', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46026/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - cols: 80, - rows: 24, - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - expect(registry.resize(record.terminalId, 132, 41)).toBe(true) - expect(record.cols).toBe(132) - expect(record.rows).toBe(41) - expect(replacementPty.write).not.toHaveBeenCalledWith('abc') - - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-1', - path: '/tmp/rollout-thread-durable-1.jsonl', - ephemeral: false, - }, - }) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.resize).toHaveBeenCalledWith(132, 41) - expect(replacementPty.write).toHaveBeenCalledWith('abc') - expect(replacementPty.resize.mock.invocationCallOrder[0]) - .toBeLessThan(replacementPty.write.mock.invocationCallOrder[0]) - }) - - it('fails a published durable replacement candidate immediately when its PTY exits before readiness', async () => { - const initialSidecar = createMockSidecar() - const firstReplacementSidecar = createMockSidecar() - const secondReplacementSidecar = createMockSidecar() - const launchFactory = vi.fn() - .mockResolvedValueOnce({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46020/' }, - sidecar: firstReplacementSidecar.api, - }) - .mockResolvedValueOnce({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46021/' }, - sidecar: secondReplacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const firstReplacementPty = await lastPty() - - firstReplacementPty.onExit.mock.calls[0][0]({ exitCode: 2, signal: 0 }) - - await vi.waitFor(() => expect(record.codex?.activeReplacement?.attempt).toBe(2), 600) - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(2), 600) - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(firstReplacementSidecar.api.shutdown).toHaveBeenCalledTimes(1) - expect(firstReplacementPty.kill).toHaveBeenCalledTimes(1) - }) - - it('fails a published durable replacement candidate immediately when fatal PTY output arrives before readiness', async () => { - const initialSidecar = createMockSidecar() - const firstReplacementSidecar = createMockSidecar() - const secondReplacementSidecar = createMockSidecar() - const launchFactory = vi.fn() - .mockResolvedValueOnce({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46022/' }, - sidecar: firstReplacementSidecar.api, - }) - .mockResolvedValueOnce({ + it('blocks input during durable recovery and sends later input only to the replacement PTY', async () => { + const planReady = deferred() + const currentSidecar = createFakeSidecar() + const replacementSidecar = createFakeSidecar() + const planCreate = vi.fn(async () => { + await planReady.promise + return { sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46023/' }, - sidecar: secondReplacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const firstReplacementPty = await lastPty() - - firstReplacementPty.onData.mock.calls[0][0]( - 'ERROR: remote app server at `ws://127.0.0.1:46022/` transport failed: WebSocket protocol error: Connection reset without closing handshake', - ) - - await vi.waitFor(() => expect(record.codex?.activeReplacement?.attempt).toBe(2), 600) - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(2), 600) - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(record.buffer.snapshot()).toContain('Connection reset without closing handshake') - expect(firstReplacementSidecar.api.shutdown).toHaveBeenCalledTimes(1) - }) - - it('does not let a dead pre-durable replacement candidate pass the stability window', async () => { - const initialSidecar = createMockSidecar() - const firstReplacementSidecar = createMockSidecar() - const secondReplacementSidecar = createMockSidecar() - const launchFactory = vi.fn() - .mockResolvedValueOnce({ - remote: { wsUrl: 'ws://127.0.0.1:46024/' }, - sidecar: firstReplacementSidecar.api, - }) - .mockResolvedValueOnce({ - remote: { wsUrl: 'ws://127.0.0.1:46025/' }, - sidecar: secondReplacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const firstReplacementPty = await lastPty() - expect(registry.input(record.terminalId, 'pre-dead')).toBe(true) - - firstReplacementPty.onExit.mock.calls[0][0]({ exitCode: 2, signal: 0 }) - - await new Promise((resolve) => setTimeout(resolve, 1_650)) - expect(record.codex?.recoveryState).toBe('recovering_pre_durable') - expect(firstReplacementPty.write).not.toHaveBeenCalledWith('pre-dead') - expect(launchFactory).toHaveBeenCalledTimes(2) - }) - - it('does not accept a current-generation non-ready durable status change as recovery readiness proof', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46014/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - replacementSidecar.emitLifecycle({ - kind: 'thread_status_changed', - threadId: 'thread-durable-1', - status: { type: 'active' }, - }) - - await Promise.resolve() - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(replacementPty.write).not.toHaveBeenCalledWith('abc') - }) - - it('accepts current-generation durable idle status as recovery readiness proof', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46016/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - replacementSidecar.emitLifecycle({ - kind: 'thread_status_changed', - threadId: 'thread-durable-1', - status: { type: 'idle' }, - }) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.write).toHaveBeenCalledWith('abc') - }) - - it('still accepts current-generation durable thread-started as recovery readiness proof', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46017/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-1', - path: '/tmp/rollout-thread-durable-1.jsonl', - ephemeral: false, - }, - }) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.write).toHaveBeenCalledWith('abc') - }) - - it('buffers input while durable Codex recovery is active', async () => { - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - const pty = await lastPty() - record.codex!.recoveryState = 'recovering_durable' - - expect(registry.input(record.terminalId, 'abc')).toBe(true) - - expect(pty.write).not.toHaveBeenCalledWith('abc') - }) - - it('handles recovery input overflow locally so ws-handler does not see an invalid terminal', async () => { - const output = vi.fn() - registry.on('terminal.output.raw', output) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - const pty = await lastPty() - record.codex!.recoveryState = 'recovering_durable' - - expect(registry.input(record.terminalId, 'x'.repeat(8 * 1024))).toBe(true) - expect(registry.input(record.terminalId, 'y')).toBe(true) - - expect(pty.write).not.toHaveBeenCalled() - expect(record.buffer.snapshot()).toContain('Codex is reconnecting; input was not sent') - expect(output).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - data: expect.stringContaining('Codex is reconnecting; input was not sent'), - })) - }) - - it('queues local recovery diagnostics behind a pending attach snapshot', async () => { - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - record.codex!.recoveryState = 'recovering_durable' - const client = createMockWs('pending-local-diagnostic') - - expect(registry.attach(record.terminalId, client as any, { pendingSnapshot: true })).toBe(record) - expect(registry.input(record.terminalId, 'x'.repeat(8 * 1024))).toBe(true) - expect(registry.input(record.terminalId, 'y')).toBe(true) - - expect(sentPayloads(client).some((payload) => - payload.type === 'terminal.output' - && payload.terminalId === record.terminalId - && String(payload.data).includes('Codex is reconnecting; input was not sent'), - )).toBe(false) - - registry.finishAttachSnapshot(record.terminalId, client as any) - - expect(sentPayloads(client).some((payload) => - payload.type === 'terminal.output' - && payload.terminalId === record.terminalId - && String(payload.data).includes('Codex is reconnecting; input was not sent'), - )).toBe(true) - }) - - it('handles recovery input expiry locally so ws-handler does not see an invalid terminal', async () => { - vi.useFakeTimers() - const output = vi.fn() - registry.on('terminal.output.raw', output) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - const pty = await lastPty() - record.codex!.recoveryState = 'recovering_durable' - - expect(registry.input(record.terminalId, 'first')).toBe(true) - vi.advanceTimersByTime(10_001) - expect(registry.input(record.terminalId, 'second')).toBe(true) - - expect(pty.write).not.toHaveBeenCalled() - expect(record.buffer.snapshot()).toContain('Codex is reconnecting; input was not sent') - expect(output).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - data: expect.stringContaining('Codex is reconnecting; input was not sent'), - })) - }) - - it('expires recovery-buffered input on the ttl even when no later input or readiness arrives', async () => { - vi.useFakeTimers() - const output = vi.fn() - registry.on('terminal.output.raw', output) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - const pty = await lastPty() - record.codex!.recoveryState = 'recovering_durable' - - expect(registry.input(record.terminalId, 'first')).toBe(true) - vi.advanceTimersByTime(10_001) - - expect(pty.write).not.toHaveBeenCalled() - expect(record.buffer.snapshot()).toContain('Codex is reconnecting; input was not sent') - expect(output).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - data: expect.stringContaining('Codex is reconnecting; input was not sent'), - })) - }) - - it('reports expired buffered input through local output when durable recovery becomes ready', async () => { - vi.useFakeTimers() - const output = vi.fn() - registry.on('terminal.output.raw', output) - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - sessionId: 'thread-durable-1', - remote: { wsUrl: 'ws://127.0.0.1:46027/' }, - sidecar: replacementSidecar.api, + remote: { wsUrl: 'ws://127.0.0.1:46003/' }, + sidecar: replacementSidecar, + } }) const record = registry.create({ mode: 'codex', cwd: '/repo', resumeSessionId: 'thread-durable-1', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - - expect(registry.input(record.terminalId, 'too-late')).toBe(true) - vi.advanceTimersByTime(10_001) - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-1', - path: '/tmp/rollout-thread-durable-1.jsonl', - ephemeral: false, - }, + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:46001/', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, }) + const [oldPty] = await spawnedPtys() - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.write).not.toHaveBeenCalledWith('too-late') - expect(record.buffer.snapshot()).toContain('Codex is reconnecting; input was not sent') - expect(output).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - data: expect.stringContaining('Codex is reconnecting; input was not sent'), - })) - }) - - it('replays local recovery diagnostics through the terminal stream broker after detach and reattach', async () => { - const broker = new TerminalStreamBroker(registry, vi.fn()) - try { - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - }) - record.codex!.recoveryState = 'recovering_durable' - - const liveWs = createMockWs('live-recovery-diagnostic') - await broker.attach(liveWs as any, record.terminalId, 'viewport_hydrate', 120, 40, 0, 'live-attach') - expect(registry.input(record.terminalId, 'x'.repeat(8 * 1024))).toBe(true) - expect(registry.input(record.terminalId, 'y')).toBe(true) - await new Promise((resolve) => setTimeout(resolve, 5)) - - expect(sentPayloads(liveWs).some((payload) => - payload.type === 'terminal.output' - && payload.terminalId === record.terminalId - && String(payload.data).includes('Codex is reconnecting; input was not sent'), - )).toBe(true) - - broker.detach(record.terminalId, liveWs as any) - const replayWs = createMockWs('replay-recovery-diagnostic') - await broker.attach(replayWs as any, record.terminalId, 'transport_reconnect', 120, 40, 0, 'replay-attach') - - const replayed = sentPayloads(replayWs) - expect(replayed.some((payload) => - payload.type === 'terminal.attach.ready' - && payload.terminalId === record.terminalId - && payload.attachRequestId === 'replay-attach', - )).toBe(true) - expect(replayed.some((payload) => - payload.type === 'terminal.output' - && payload.terminalId === record.terminalId - && payload.attachRequestId === 'replay-attach' - && String(payload.data).includes('Codex is reconnecting; input was not sent'), - )).toBe(true) - } finally { - broker.close() - } - }) - - it('makes pre-durable recovery live only after the attach-stability window and then flushes input', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const status = vi.fn() - registry.on('terminal.status', status) - const launchFactory = vi.fn().mockResolvedValue({ - remote: { wsUrl: 'ws://127.0.0.1:46005/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) + await vi.waitFor(() => expect(planCreate).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - expect(record.codex?.recoveryState).toBe('recovering_pre_durable') - expect(registry.input(record.terminalId, 'pre')).toBe(true) - expect(replacementPty.write).not.toHaveBeenCalledWith('pre') - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_live_only'), 2_000) - expect(replacementPty.write).toHaveBeenCalledWith('pre') - expect(status).toHaveBeenCalledWith(expect.objectContaining({ + expect(registry.input(record.terminalId, 'during recovery')).toEqual({ + status: 'blocked_codex_recovery_pending', terminalId: record.terminalId, - status: 'running', - })) - }) - - it('cancels pre-durable stability when durable promotion arrives before the window elapses', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar() - const launchFactory = vi.fn().mockResolvedValue({ - remote: { wsUrl: 'ws://127.0.0.1:46015/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.waitFor(() => expect(record.codex?.workerGeneration).toBe(2)) - const replacementPty = await lastPty() - expect(registry.input(record.terminalId, 'late-durable')).toBe(true) + expect(oldPty.write).not.toHaveBeenCalledWith('during recovery') - replacementSidecar.emitDurableSession('thread-durable-late') - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('recovering_durable')) + planReady.resolve() + await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1)) + const [, replacementPty] = await spawnedPtys() - await new Promise((resolve) => setTimeout(resolve, 1_600)) - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(replacementPty.write).not.toHaveBeenCalledWith('late-durable') - - replacementSidecar.emitLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-durable-late', - path: '/tmp/rollout-thread-durable-late.jsonl', - ephemeral: false, - }, - }) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable')) - expect(replacementPty.write).toHaveBeenCalledWith('late-durable') + expect(registry.input(record.terminalId, 'after recovery')).toEqual({ status: 'written' }) + expect(oldPty.write).not.toHaveBeenCalledWith('after recovery') + expect(replacementPty.write).toHaveBeenCalledWith('after recovery') }) - it('latches fast candidate readiness before unpublished durable identity is replayed', async () => { - const initialSidecar = createMockSidecar() - const replacementSidecar = createMockSidecar({ - onAttach: (attachment) => { - attachment.onThreadLifecycle({ - kind: 'thread_started', - thread: { - id: 'thread-fast-candidate', - path: '/tmp/rollout-thread-fast-candidate.jsonl', - ephemeral: false, - }, - }) - attachment.onDurableSession('thread-fast-candidate') - }, - }) - const launchFactory = vi.fn().mockResolvedValue({ - remote: { wsUrl: 'ws://127.0.0.1:46029/' }, - sidecar: replacementSidecar.api, - }) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - codexSidecar: initialSidecar.api, - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - expect(registry.input(record.terminalId, 'fast')).toBe(true) - - await vi.waitFor(() => expect(record.codex?.recoveryState).toBe('running_durable'), 600) - const replacementPty = await lastPty() - expect(record.codex?.durableSessionId).toBe('thread-fast-candidate') - expect(replacementPty.write).toHaveBeenCalledWith('fast') - }) - - it('keeps retrying durable Codex resume after repeated replacement launch failures', async () => { - vi.useFakeTimers() + it('keeps non-durable Codex PTY exit final', async () => { const exited = vi.fn() - const status = vi.fn() registry.on('terminal.exit', exited) - registry.on('terminal.status', status) - const launchFactory = vi.fn().mockRejectedValue(new Error('replacement launch unavailable')) - const record = registry.create({ - mode: 'codex', - cwd: '/repo', - resumeSessionId: 'thread-durable-1', - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) + const record = registry.create({ mode: 'codex', cwd: '/repo' }) + const [pty] = await spawnedPtys() - for (let index = 0; index < 16; index += 1) { - await vi.runOnlyPendingTimersAsync() - await Promise.resolve() - } + pty.onExit.mock.calls[0][0]({ exitCode: 2, signal: 0 }) - expect(launchFactory.mock.calls.length).toBeGreaterThan(5) - expect(record.status).toBe('running') - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(exited).not.toHaveBeenCalled() - expect(status).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - status: 'recovering', - })) - expect(status).not.toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - status: 'recovery_failed', - })) - expect(launchFactory.mock.calls.every(([input]) => input.resumeSessionId === 'thread-durable-1')).toBe(true) + expect(registry.get(record.terminalId)?.status).toBe('exited') + expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 2 }) }) - it('retires the failed worker and schedules another durable resume attempt after many failures', async () => { - vi.useFakeTimers() - const sidecar = createMockSidecar() - const status = vi.fn() - registry.on('terminal.status', status) - const launchFactory = vi.fn().mockRejectedValue(new Error('still unavailable')) + it('does not start durable recovery for an explicit user close', async () => { + const currentSidecar = createFakeSidecar() + const planCreate = vi.fn() const record = registry.create({ mode: 'codex', cwd: '/repo', resumeSessionId: 'thread-durable-1', - codexSidecar: sidecar.api, - codexLaunchFactory: launchFactory, + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:46001/', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, }) - const failedPty = await lastPty() - - for (let index = 0; index < 5; index += 1) { - record.codex!.recoveryPolicy.nextAttempt() - } - failedPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - await vi.runOnlyPendingTimersAsync() - await Promise.resolve() + registry.kill(record.terminalId) - expect(record.codex?.retiringGenerations.has(1)).toBe(true) - expect(record.codex?.closeReasonByGeneration.get(1)).toBe('recovery_retire') - expect(sidecar.api.shutdown).toHaveBeenCalledTimes(1) - expect(failedPty.kill).toHaveBeenCalledTimes(1) - expect(record.codex?.recoveryState).toBe('recovering_durable') - expect(status).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'recovery_failed' })) - expect(launchFactory).toHaveBeenCalled() + expect(planCreate).not.toHaveBeenCalled() + expect(registry.get(record.terminalId)?.status).toBe('exited') + await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1)) }) - it('does not commit durable identity from a failed unpublished replacement candidate', async () => { - const status = vi.fn() - registry.on('terminal.status', status) - const pty = await import('node-pty') - let spawnCount = 0 - vi.mocked(pty.spawn).mockImplementation(() => { - spawnCount += 1 - if (spawnCount === 2) { - throw new Error('candidate spawn failed') - } - return createMockPty() as any - }) - - const firstReplacementSidecar = createMockSidecar({ - onAttach: (attachment) => { - attachment.onDurableSession('failed-unpublished-session') - }, - }) - const secondReplacementSidecar = createMockSidecar() - const launchFactory = vi.fn() - .mockResolvedValueOnce({ - remote: { wsUrl: 'ws://127.0.0.1:46018/' }, - sidecar: firstReplacementSidecar.api, - }) - .mockResolvedValueOnce({ - remote: { wsUrl: 'ws://127.0.0.1:46019/' }, - sidecar: secondReplacementSidecar.api, - }) - + it('runs normal PTY-exit cleanup when durable recovery is already blocked', async () => { + const exited = vi.fn() + registry.on('terminal.exit', exited) + const currentSidecar = createFakeSidecar() + const planCreate = vi.fn() const record = registry.create({ - mode: 'codex', - cwd: '/repo', - codexLaunchFactory: launchFactory, - }) - const oldPty = await lastPty() - oldPty.onExit.mock.calls[0][0]({ exitCode: 1, signal: 0 }) - - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(record.codex?.activeReplacement?.attempt).toBe(2)) - expect(status).toHaveBeenCalledWith(expect.objectContaining({ - terminalId: record.terminalId, - status: 'recovering', - reason: 'replacement_spawn_failure', - attempt: 2, - })) - - expect(record.codex?.durableSessionId).toBeUndefined() - expect(launchFactory.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ - resumeSessionId: undefined, - })) - - await new Promise((resolve) => setTimeout(resolve, 300)) - await vi.waitFor(() => expect(launchFactory).toHaveBeenCalledTimes(2)) - - expect(launchFactory.mock.calls[1]?.[0]).toEqual(expect.objectContaining({ - resumeSessionId: undefined, - })) - }) - - it('does not idle-kill detached Codex recovery states but still kills ordinary detached terminals', async () => { - const settings = { - safety: { autoKillIdleMinutes: 1 }, - terminal: {}, - } as any - registry.shutdown() - registry = new TerminalRegistry(settings, 10) - - const recoveringPreDurable = registry.create({ mode: 'codex', cwd: '/repo' }) - recoveringPreDurable.codex!.recoveryState = 'recovering_pre_durable' - recoveringPreDurable.lastActivityAt = Date.now() - 120_000 - - const recoveringDurable = registry.create({ mode: 'codex', cwd: '/repo', resumeSessionId: 'thread-durable-1', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:46001/', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, }) - recoveringDurable.codex!.recoveryState = 'recovering_durable' - recoveringDurable.lastActivityAt = Date.now() - 120_000 - - const shell = registry.create({ mode: 'shell', cwd: '/repo' }) - shell.lastActivityAt = Date.now() - 120_000 + const [pty] = await spawnedPtys() + record.codexRecoveryBlockedError = new Error('previous teardown failed') - await registry.enforceIdleKillsForTest() + pty.onExit.mock.calls[0][0]({ exitCode: 9, signal: 0 }) - expect(recoveringPreDurable.status).toBe('running') - expect(recoveringDurable.status).toBe('running') - expect(shell.status).toBe('exited') + expect(planCreate).not.toHaveBeenCalled() + expect(registry.get(record.terminalId)?.status).toBe('exited') + expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 9 }) }) }) diff --git a/test/unit/server/terminal-registry.codex-sidecar.test.ts b/test/unit/server/terminal-registry.codex-sidecar.test.ts index 0597f8a89..98a4cde3f 100644 --- a/test/unit/server/terminal-registry.codex-sidecar.test.ts +++ b/test/unit/server/terminal-registry.codex-sidecar.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' const mockPtyProcess = vi.hoisted(() => { const createMockPty = () => { @@ -52,11 +55,13 @@ vi.mock('../../../server/logger', () => { child: vi.fn(), } logger.child.mockReturnValue(logger) - return { logger } + return { logger, sessionLifecycleLogger: logger } }) import { TerminalRegistry } from '../../../server/terminal-registry.js' +import { CodexDurabilityStore } from '../../../server/coding-cli/codex-app-server/durability-store.js' import { logger } from '../../../server/logger.js' +import { CODEX_DURABILITY_SCHEMA_VERSION } from '../../../shared/codex-durability.js' function deferred<T = void>() { let resolve!: (value: T | PromiseLike<T>) => void @@ -69,19 +74,70 @@ function deferred<T = void>() { } function createFakeSidecar(options: { - waitForLoadedThread?: () => Promise<void> + adopt?: () => Promise<void> shutdown?: () => Promise<void> } = {}) { const lifecycleLossHandlers = new Set<(event: unknown) => void>() + const candidateHandlers = new Set<(event: any) => void>() + const turnStartedHandlers = new Set<(event: any) => void>() + const turnCompletedHandlers = new Set<(event: any) => void>() + const repairHandlers = new Set<(event: any) => void>() + const fsChangedHandlers = new Set<(event: any) => void>() return { - adopt: vi.fn(async () => undefined), - listLoadedThreads: vi.fn(async () => ['thread-1']), - waitForLoadedThread: vi.fn(options.waitForLoadedThread ?? (async () => undefined)), + adopt: vi.fn(options.adopt ?? (async () => undefined)), shutdown: vi.fn(options.shutdown ?? (async () => undefined)), + markCandidatePersisted: vi.fn(), + watchPath: vi.fn(async (targetPath: string) => ({ path: targetPath })), + unwatchPath: vi.fn(async () => undefined), + onCandidate: vi.fn((handler: (event: any) => void) => { + candidateHandlers.add(handler) + return () => candidateHandlers.delete(handler) + }), + onTurnStarted: vi.fn((handler: (event: any) => void) => { + turnStartedHandlers.add(handler) + return () => turnStartedHandlers.delete(handler) + }), + onTurnCompleted: vi.fn((handler: (event: any) => void) => { + turnCompletedHandlers.add(handler) + return () => turnCompletedHandlers.delete(handler) + }), + onRepairTrigger: vi.fn((handler: (event: any) => void) => { + repairHandlers.add(handler) + return () => repairHandlers.delete(handler) + }), + onFsChanged: vi.fn((handler: (event: any) => void) => { + fsChangedHandlers.add(handler) + return () => fsChangedHandlers.delete(handler) + }), onLifecycleLoss: vi.fn((handler: (event: unknown) => void) => { lifecycleLossHandlers.add(handler) return () => lifecycleLossHandlers.delete(handler) }), + emitCandidate(event: any) { + for (const handler of candidateHandlers) { + handler(event) + } + }, + emitTurnStarted(event: any) { + for (const handler of turnStartedHandlers) { + handler(event) + } + }, + emitTurnCompleted(event: any) { + for (const handler of turnCompletedHandlers) { + handler(event) + } + }, + emitRepairTrigger(event: any) { + for (const handler of repairHandlers) { + handler(event) + } + }, + emitFsChanged(event: any) { + for (const handler of fsChangedHandlers) { + handler(event) + } + }, emitLifecycleLoss(event: unknown) { for (const handler of lifecycleLossHandlers) { handler(event) @@ -96,6 +152,1000 @@ describe('TerminalRegistry Codex sidecar ownership', () => { vi.clearAllMocks() }) + it('persists Codex restore identity server-side before releasing fresh terminal input', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + envContext: { tabId: 'tab-1', paneId: 'pane-1' }, + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_pending', + terminalId: term.terminalId, + }) + expect(mockPtyProcess.instances[0].write).not.toHaveBeenCalled() + + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_started_notification', + thread: { + id: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + path: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + ephemeral: false, + }, + }) + + await vi.waitFor(() => expect(sidecar.markCandidatePersisted).toHaveBeenCalledTimes(1)) + const record = registry.get(term.terminalId)! + expect(record.codexInputGate).toBeUndefined() + expect(record.codexDurability).toMatchObject({ + state: 'captured_pre_turn', + candidate: { + candidateThreadId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + source: 'thread_started_notification', + }, + }) + + const stored = await new CodexDurabilityStore({ dir: durabilityDir }).read(term.terminalId) + expect(stored).toMatchObject({ + terminalId: term.terminalId, + tabId: 'tab-1', + paneId: 'pane-1', + serverInstanceId: 'srv-test', + state: 'captured_pre_turn', + candidate: { + candidateThreadId: '019e2a0c-7cef-7281-94df-d0d05d7b9ac3', + rolloutPath: '/home/user/.codex/sessions/2026/05/14/rollout.jsonl', + }, + }) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'terminal.codex.durability.updated', + terminalId: term.terminalId, + durability: expect.objectContaining({ + state: 'captured_pre_turn', + }), + })) + + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ status: 'written' }) + expect(mockPtyProcess.instances[0].write).toHaveBeenCalledWith('hello\r') + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('allows only terminal startup control replies while Codex restore identity is pending', () => { + const registry = new TerminalRegistry() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: createFakeSidecar(), + }, + } as any, + }) + + for (const data of [ + '\x1b[1;1R', + '\x1b[I', + '\x1b[?1;2c', + '\x1b]10;rgb:2424/2929/2f2f\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + ]) { + expect(registry.input(term.terminalId, data)).toEqual({ status: 'written' }) + expect(mockPtyProcess.instances[0].write).toHaveBeenLastCalledWith(data) + } + + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_pending', + terminalId: term.terminalId, + }) + expect(registry.input(term.terminalId, '\x1b[A')).toEqual({ + status: 'blocked_codex_identity_pending', + terminalId: term.terminalId, + }) + }) + + it('keeps reporting the Codex identity capture timeout after closing the failed terminal', async () => { + const registry = new TerminalRegistry() + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitRepairTrigger({ kind: 'candidate_capture_timeout' }) + + await vi.waitFor(() => { + expect(registry.get(term.terminalId)?.status).toBe('exited') + }) + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_capture_timeout', + terminalId: term.terminalId, + }) + }) + + it('does not release fresh Codex input from a browser persistence acknowledgement alone', () => { + const registry = new TerminalRegistry() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: createFakeSidecar(), + }, + } as any, + }) + + expect(registry.acknowledgeCodexCandidatePersisted({ + terminalId: term.terminalId, + candidateThreadId: 'thread-1', + rolloutPath: '/home/user/.codex/sessions/rollout.jsonl', + })).toBe('no_candidate') + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_pending', + terminalId: term.terminalId, + }) + }) + + it('deletes the transient Codex durability store record when the terminal is killed', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const store = new CodexDurabilityStore({ dir: durabilityDir }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-delete-store', + path: path.join(durabilityDir, 'rollout.jsonl'), + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await expect(store.read(term.terminalId)).resolves.toMatchObject({ + terminalId: term.terminalId, + state: 'captured_pre_turn', + }) + + await registry.killAndWait(term.terminalId) + + await vi.waitFor(async () => { + await expect(store.read(term.terminalId)).resolves.toBeUndefined() + }) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('marks fresh Codex non-restorable and closes it when candidate capture times out', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitRepairTrigger({ kind: 'candidate_capture_timeout' }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'non_restorable', + nonRestorableReason: 'candidate_capture_timeout', + }) + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_capture_timeout', + terminalId: term.terminalId, + }) + expect(mockPtyProcess.instances[0].kill).toHaveBeenCalledTimes(1) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('discards a delayed candidate write after candidate capture already timed out', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + const firstCandidateWriteStarted = deferred() + const releaseFirstCandidateWrite = deferred() + let writeCount = 0 + const fsImpl = { + mkdir: fsp.mkdir, + readdir: fsp.readdir, + readFile: fsp.readFile, + rename: fsp.rename, + unlink: fsp.unlink, + writeFile: vi.fn(async (...args: Parameters<typeof fsp.writeFile>) => { + writeCount += 1 + if (writeCount === 1) { + firstCandidateWriteStarted.resolve() + await releaseFirstCandidateWrite.promise + } + return fsp.writeFile(...args) + }), + } + try { + const store = new CodexDurabilityStore({ dir: durabilityDir, fsImpl }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-late-candidate', + path: path.join(durabilityDir, 'rollout.jsonl'), + ephemeral: false, + }, + }) + await firstCandidateWriteStarted.promise + + sidecar.emitRepairTrigger({ kind: 'candidate_capture_timeout' }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'non_restorable', + nonRestorableReason: 'candidate_capture_timeout', + }) + + releaseFirstCandidateWrite.resolve() + + await vi.waitFor(() => { + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'non_restorable', + nonRestorableReason: 'candidate_capture_timeout', + }) + }) + await vi.waitFor(async () => { + await expect(store.read(term.terminalId)).resolves.toBeUndefined() + }) + expect(sidecar.markCandidatePersisted).not.toHaveBeenCalled() + } finally { + releaseFirstCandidateWrite.resolve() + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('serializes per-terminal Codex candidate persistence so the first deterministic candidate wins', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + const firstCandidateWriteStarted = deferred() + const releaseFirstCandidateWrite = deferred() + class StoreWithDelayedFirstCandidateWrite extends CodexDurabilityStore { + readonly writeThreadIds: string[] = [] + + override async write(...args: Parameters<CodexDurabilityStore['write']>) { + const threadId = args[0].candidate?.candidateThreadId + if (threadId) this.writeThreadIds.push(threadId) + if (threadId === 'thread-first') { + firstCandidateWriteStarted.resolve() + await releaseFirstCandidateWrite.promise + } + return super.write(...args) + } + } + + try { + const store = new StoreWithDelayedFirstCandidateWrite({ dir: durabilityDir }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-first', + path: path.join(durabilityDir, 'first-rollout.jsonl'), + ephemeral: false, + }, + }) + await firstCandidateWriteStarted.promise + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-second', + path: path.join(durabilityDir, 'second-rollout.jsonl'), + ephemeral: false, + }, + }) + await Promise.resolve() + expect(store.writeThreadIds).toEqual(['thread-first']) + + releaseFirstCandidateWrite.resolve() + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.candidate?.candidateThreadId).toBe('thread-first')) + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalId: term.terminalId, + existingThreadId: 'thread-first', + candidateThreadId: 'thread-second', + }), + 'Ignoring mismatched Codex restore identity candidate after one was already persisted', + )) + await expect(store.read(term.terminalId)).resolves.toMatchObject({ + candidate: { + candidateThreadId: 'thread-first', + rolloutPath: path.join(durabilityDir, 'first-rollout.jsonl'), + }, + }) + expect(store.writeThreadIds).toEqual(['thread-first']) + expect(sidecar.markCandidatePersisted).toHaveBeenCalledTimes(1) + } finally { + releaseFirstCandidateWrite.resolve() + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('closes the terminal when candidate persistence fails before user input', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + class StoreWithFirstWriteFailure extends CodexDurabilityStore { + private writeCount = 0 + + override async write(...args: Parameters<CodexDurabilityStore['write']>) { + this.writeCount += 1 + if (this.writeCount === 1) { + throw new Error('candidate write failed') + } + return super.write(...args) + } + } + + try { + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new StoreWithFirstWriteFailure({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-write-failed', + path: path.join(durabilityDir, 'rollout.jsonl'), + ephemeral: false, + }, + }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'non_restorable', + nonRestorableReason: 'candidate_persist_failed', + })) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_unavailable', + terminalId: term.terminalId, + reason: 'candidate_persist_failed', + }) + expect(mockPtyProcess.instances[0].write).not.toHaveBeenCalled() + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('promotes Codex to canonical session identity after turn completion rollout proof succeeds', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-proof-ok', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-proof-ok"}}\n', + 'utf8', + ) + sidecar.emitTurnStarted({ threadId: 'thread-proof-ok', turnId: 'turn-1', params: {} }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('turn_in_progress_unproven')) + sidecar.emitTurnCompleted({ threadId: 'thread-proof-ok', turnId: 'turn-1', params: {} }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.resumeSessionId).toBe('thread-proof-ok')) + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-proof-ok', + }) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'terminal.session.associated', + terminalId: term.terminalId, + sessionRef: { + provider: 'codex', + sessionId: 'thread-proof-ok', + }, + })) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('persists and broadcasts durable Codex identity promoted from create-time proof', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const store = new CodexDurabilityStore({ dir: durabilityDir }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + envContext: { tabId: 'tab-create-proof', paneId: 'pane-create-proof' }, + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-create-candidate', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + + await expect(registry.promoteCodexDurabilityFromCreateProof( + term.terminalId, + 'thread-create-durable', + 12345, + )).resolves.toEqual({ + ok: true, + terminalId: term.terminalId, + sessionId: 'thread-create-durable', + }) + + expect(registry.get(term.terminalId)?.resumeSessionId).toBe('thread-create-durable') + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-create-durable', + candidate: { + candidateThreadId: 'thread-create-candidate', + rolloutPath, + }, + }) + await expect(store.read(term.terminalId)).resolves.toMatchObject({ + terminalId: term.terminalId, + tabId: 'tab-create-proof', + paneId: 'pane-create-proof', + serverInstanceId: 'srv-test', + state: 'durable', + durableThreadId: 'thread-create-durable', + candidate: { + candidateThreadId: 'thread-create-candidate', + rolloutPath, + }, + updatedAt: 12345, + }) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'terminal.codex.durability.updated', + terminalId: term.terminalId, + durability: expect.objectContaining({ + state: 'durable', + durableThreadId: 'thread-create-durable', + }), + })) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('uses the bindSession result when promoting create-time Codex durability', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const store = new CodexDurabilityStore({ dir: durabilityDir }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: createFakeSidecar(), + }, + } as any, + }) + vi.spyOn(registry, 'bindSession').mockImplementation((terminalId) => { + registry.get(terminalId)!.resumeSessionId = 'stale-side-effect' + return { ok: true, terminalId, sessionId: 'thread-create-durable' } + }) + + await expect(registry.promoteCodexDurabilityFromCreateProof( + term.terminalId, + 'thread-create-durable', + 67890, + )).resolves.toEqual({ + ok: true, + terminalId: term.terminalId, + sessionId: 'thread-create-durable', + }) + + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-create-durable', + }) + expect(registry.get(term.terminalId)?.resumeSessionId).toBe('thread-create-durable') + await expect(store.read(term.terminalId)).resolves.toMatchObject({ + state: 'durable', + durableThreadId: 'thread-create-durable', + updatedAt: 67890, + }) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('does not broadcast a durable Codex session when rollout proof cannot bind canonical ownership', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const owner = registry.create({ + mode: 'codex', + resumeSessionId: 'thread-binding-owner', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-binding-owner', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-binding-owner"}}\n', + 'utf8', + ) + sidecar.emitTurnCompleted({ threadId: 'thread-binding-owner', turnId: 'turn-1', params: {} }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'non_restorable', + nonRestorableReason: 'session_binding_failed:session_already_owned', + })) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.get(term.terminalId)?.resumeSessionId).toBeUndefined() + expect(registry.findRunningTerminalBySession('codex', 'thread-binding-owner')?.terminalId).toBe(owner.terminalId) + expect(registry.input(term.terminalId, 'hello\r')).toEqual({ + status: 'blocked_codex_identity_unavailable', + terminalId: term.terminalId, + reason: 'session_binding_failed:session_already_owned', + }) + expect(sent).not.toContainEqual(expect.objectContaining({ + type: 'terminal.session.associated', + })) + expect(mockPtyProcess.instances.at(-1)?.kill).toHaveBeenCalledTimes(1) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('does not promote Codex from repair triggers before a turn completes', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-repair-pre-turn', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-repair-pre-turn"}}\n', + 'utf8', + ) + + sidecar.emitRepairTrigger({ kind: 'fs_changed' }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(registry.get(term.terminalId)?.resumeSessionId).toBeUndefined() + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'captured_pre_turn', + candidate: { + candidateThreadId: 'thread-repair-pre-turn', + }, + }) + expect(sent).not.toContainEqual(expect.objectContaining({ + type: 'terminal.session.associated', + })) + + sidecar.emitTurnCompleted({ threadId: 'thread-repair-pre-turn', turnId: 'turn-1', params: {} }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.resumeSessionId).toBe('thread-repair-pre-turn')) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('runs a final rollout proof before marking a fresh Codex PTY exit non-restorable', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-final-proof', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-final-proof"}}\n', + 'utf8', + ) + + mockPtyProcess.instances[0]._emitExit(137) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-final-proof', + }) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'terminal.session.associated', + terminalId: term.terminalId, + sessionRef: { + provider: 'codex', + sessionId: 'thread-final-proof', + }, + })) + expect(logger.warn).not.toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'terminal_exit_without_durable_session', + terminalId: term.terminalId, + }), + 'terminal_exit_without_durable_session', + ) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('runs a final rollout proof before deciding lifecycle loss cannot recover a fresh Codex terminal', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'rollout.jsonl') + const replacementSidecar = createFakeSidecar() + const planCreate = vi.fn(async () => ({ + sessionId: 'thread-final-recovery', + remote: { wsUrl: 'ws://127.0.0.1:43124' }, + sidecar: replacementSidecar, + })) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const currentSidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, + }) + + currentSidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-final-recovery', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-final-recovery"}}\n', + 'utf8', + ) + + currentSidecar.emitLifecycleLoss({ method: 'thread/closed' }) + + await vi.waitFor(() => expect(planCreate).toHaveBeenCalledWith(expect.objectContaining({ + terminalId: term.terminalId, + resumeSessionId: 'thread-final-recovery', + }))) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: term.terminalId, generation: 1 })) + expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-final-recovery', + }) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('marks Codex degraded after turn completion rollout proof fails', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'missing-rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + const sent: unknown[] = [] + const client = { + readyState: 1, + bufferedAmount: 0, + send: vi.fn((message: string) => sent.push(JSON.parse(message))), + } + registry.attach(term.terminalId, client as any) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-proof-missing', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + sidecar.emitTurnCompleted({ threadId: 'thread-proof-missing', turnId: 'turn-1', params: {} }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('durability_unproven_after_completion')) + expect(registry.get(term.terminalId)?.resumeSessionId).toBeUndefined() + expect(registry.get(term.terminalId)?.codexDurability?.lastProofFailure).toMatchObject({ + reason: 'missing', + }) + expect(sent).not.toContainEqual(expect.objectContaining({ + type: 'terminal.session.associated', + })) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + + it('uses exact rollout watch changes as a one-shot repair trigger after proof failure', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const rolloutPath = path.join(durabilityDir, 'late-rollout.jsonl') + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: new CodexDurabilityStore({ dir: durabilityDir }), + serverInstanceId: 'srv-test', + }) + const sidecar = createFakeSidecar() + const term = registry.create({ + mode: 'codex', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, + }) + + sidecar.emitCandidate({ + source: 'thread_start_response', + thread: { + id: 'thread-late-proof', + path: rolloutPath, + ephemeral: false, + }, + }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('captured_pre_turn')) + await vi.waitFor(() => expect(sidecar.watchPath).toHaveBeenCalledWith(rolloutPath, expect.any(String))) + const watchId = sidecar.watchPath.mock.calls[0][1] + + sidecar.emitTurnCompleted({ threadId: 'thread-late-proof', turnId: 'turn-1', params: {} }) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability?.state).toBe('durability_unproven_after_completion')) + + await fsp.writeFile( + rolloutPath, + '{"type":"session_meta","payload":{"id":"thread-late-proof"}}\n', + 'utf8', + ) + sidecar.emitFsChanged({ watchId, changedPaths: [rolloutPath] }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexDurability).toMatchObject({ + state: 'durable', + durableThreadId: 'thread-late-proof', + })) + expect(sidecar.unwatchPath).toHaveBeenCalledWith(watchId) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + it('awaits Codex sidecar teardown when killing a terminal', async () => { const registry = new TerminalRegistry() const shutdown = vi.fn(async () => undefined) @@ -171,7 +1221,7 @@ describe('TerminalRegistry Codex sidecar ownership', () => { expect(currentSidecar.onLifecycleLoss).toHaveBeenCalledTimes(1) currentSidecar.emitLifecycleLoss({ method: 'thread/status/changed', threadId: 'thread-1', status: 'notLoaded' }) - await vi.waitFor(() => expect(replacementSidecar.waitForLoadedThread).toHaveBeenCalledWith('thread-1', expect.any(Object))) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: term.terminalId, generation: 1 })) expect(registry.get(term.terminalId)?.status).toBe('running') expect(planCreate).toHaveBeenCalledWith(expect.objectContaining({ @@ -180,10 +1230,12 @@ describe('TerminalRegistry Codex sidecar ownership', () => { })) expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: term.terminalId, generation: 1 }) expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1) - expect(mockPtyProcess.instances[0].kill).toHaveBeenCalled() + expect(mockPtyProcess.instances[0].kill).toHaveBeenCalledWith('SIGTERM') + await new Promise((resolve) => setTimeout(resolve, 600)) + expect(mockPtyProcess.instances[0].kill).toHaveBeenCalledTimes(1) expect(mockPtyProcess.instances[1].write).toBeDefined() - expect(registry.input(term.terminalId, 'after recovery')).toBe(true) + expect(registry.input(term.terminalId, 'after recovery')).toEqual({ status: 'written' }) expect(mockPtyProcess.instances[0].write).not.toHaveBeenCalled() expect(mockPtyProcess.instances[1].write).toHaveBeenCalledWith('after recovery') }) @@ -244,12 +1296,12 @@ describe('TerminalRegistry Codex sidecar ownership', () => { registry.publishCodexSidecar(term.terminalId) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(replacementSidecar.waitForLoadedThread).toHaveBeenCalledWith('thread-1', expect.any(Object))) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledWith({ terminalId: term.terminalId, generation: 1 })) expect(planCreate).toHaveBeenCalledTimes(1) }) - it('keeps the old Codex generation current when retiring sidecar teardown fails', async () => { + it('closes the Codex terminal when retiring sidecar teardown blocks recovery', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar({ shutdown: async () => { @@ -275,12 +1327,13 @@ describe('TerminalRegistry Codex sidecar ownership', () => { }) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalled()) await vi.waitFor(() => expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(1)) expect(planCreate).toHaveBeenCalledTimes(1) - expect(registry.input(term.terminalId, 'still old generation')).toBe(true) - expect(mockPtyProcess.instances[0].write).toHaveBeenCalledWith('still old generation') + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(registry.input(term.terminalId, 'still old generation')).toEqual({ status: 'not_running' }) + expect(mockPtyProcess.instances[0].write).not.toHaveBeenCalledWith('still old generation') expect(mockPtyProcess.instances[1].write).not.toHaveBeenCalled() }) @@ -310,13 +1363,15 @@ describe('TerminalRegistry Codex sidecar ownership', () => { }) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(currentSidecar.shutdown).toHaveBeenCalled()) await vi.waitFor(() => expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(1)) + const currentShutdownCalls = currentSidecar.shutdown.mock.calls.length currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) await new Promise((resolve) => setTimeout(resolve, 25)) expect(planCreate).toHaveBeenCalledTimes(1) + expect(currentSidecar.shutdown).toHaveBeenCalledTimes(currentShutdownCalls) expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(1) }) @@ -324,8 +1379,8 @@ describe('TerminalRegistry Codex sidecar ownership', () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() const replacementSidecar = createFakeSidecar({ - waitForLoadedThread: async () => { - throw new Error('candidate never became ready') + adopt: async () => { + mockPtyProcess.instances[1]._emitExit(42) }, shutdown: async () => { throw new Error('candidate sidecar teardown failed') @@ -394,6 +1449,39 @@ describe('TerminalRegistry Codex sidecar ownership', () => { } }) + it('closes a Codex terminal when lifecycle-loss durable recovery becomes blocked', async () => { + const registry = new TerminalRegistry() + const exited = vi.fn() + registry.on('terminal.exit', exited) + const currentSidecar = createFakeSidecar() + const teardownError = new Error('planner-owned sidecar teardown failed') as Error & { + codexSidecarTeardownFailed?: boolean + } + teardownError.codexSidecarTeardownFailed = true + const planCreate = vi.fn(async () => { + throw teardownError + }) + const term = registry.create({ + mode: 'codex', + resumeSessionId: 'thread-1', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, + }) + mockPtyProcess.instances[0].autoExitOnKill = false + + currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexRecoveryBlockedError).toBe(teardownError)) + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + expect(planCreate).toHaveBeenCalledTimes(1) + expect(exited).toHaveBeenCalledWith({ terminalId: term.terminalId, exitCode: 0 }) + }) + it('keeps unpublished candidate teardown failure retryable for final close', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() @@ -401,8 +1489,8 @@ describe('TerminalRegistry Codex sidecar ownership', () => { .mockRejectedValueOnce(new Error('candidate verified teardown failed')) .mockResolvedValueOnce(undefined) const replacementSidecar = createFakeSidecar({ - waitForLoadedThread: async () => { - throw new Error('candidate never became ready') + adopt: async () => { + mockPtyProcess.instances[1]._emitExit(42) }, shutdown: candidateShutdown, }) @@ -439,8 +1527,8 @@ describe('TerminalRegistry Codex sidecar ownership', () => { .mockRejectedValueOnce(new Error('candidate verified teardown failed')) .mockResolvedValueOnce(undefined) const replacementSidecar = createFakeSidecar({ - waitForLoadedThread: async () => { - throw new Error('candidate never became ready') + adopt: async () => { + mockPtyProcess.instances[1]._emitExit(42) }, shutdown: candidateShutdown, }) @@ -468,12 +1556,13 @@ describe('TerminalRegistry Codex sidecar ownership', () => { expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(2) }) - it('does not publish a recovery candidate whose PTY exited before readiness completed', async () => { + it('does not publish a recovery candidate whose PTY exited before publication', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() - const readiness = deferred() const firstCandidate = createFakeSidecar({ - waitForLoadedThread: () => readiness.promise, + adopt: async () => { + mockPtyProcess.instances[1]._emitExit(42) + }, }) const secondCandidate = createFakeSidecar() const planCreate = vi.fn() @@ -500,9 +1589,6 @@ describe('TerminalRegistry Codex sidecar ownership', () => { }) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(firstCandidate.waitForLoadedThread).toHaveBeenCalledTimes(1)) - mockPtyProcess.instances[1]._emitExit(42) - readiness.resolve() await vi.waitFor(() => expect(firstCandidate.shutdown).toHaveBeenCalledTimes(1)) await vi.waitFor(() => expect(secondCandidate.adopt).toHaveBeenCalledTimes(1)) @@ -511,7 +1597,7 @@ describe('TerminalRegistry Codex sidecar ownership', () => { expect(planCreate).toHaveBeenCalledTimes(2) expect(currentSidecar.shutdown).toHaveBeenCalledTimes(1) expect(firstCandidate.shutdown).toHaveBeenCalledTimes(1) - expect(registry.input(term.terminalId, 'after retry')).toBe(true) + expect(registry.input(term.terminalId, 'after retry')).toEqual({ status: 'written' }) expect(mockPtyProcess.instances[1].write).not.toHaveBeenCalled() expect(mockPtyProcess.instances[2].write).toHaveBeenCalledWith('after retry') }) @@ -549,18 +1635,70 @@ describe('TerminalRegistry Codex sidecar ownership', () => { expect(oldPtyExitedDuringShutdown).toBe(true) expect(registry.get(term.terminalId)?.status).toBe('running') expect(replacementSidecar.shutdown).not.toHaveBeenCalled() - expect(registry.input(term.terminalId, 'after atomic handoff')).toBe(true) + expect(registry.input(term.terminalId, 'after atomic handoff')).toEqual({ status: 'written' }) expect(mockPtyProcess.instances[0].write).not.toHaveBeenCalled() expect(mockPtyProcess.instances[1].write).toHaveBeenCalledWith('after atomic handoff') }) + it('deletes Codex durability store records when a published recovery PTY exits finally', async () => { + const durabilityDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-durability-')) + try { + const store = new CodexDurabilityStore({ dir: durabilityDir }) + const registry = new TerminalRegistry(undefined, undefined, undefined, { + codexDurabilityStore: store, + serverInstanceId: 'srv-test', + }) + const currentSidecar = createFakeSidecar() + const replacementSidecar = createFakeSidecar() + const planCreate = vi.fn(async () => ({ + sessionId: 'thread-1', + remote: { wsUrl: 'ws://127.0.0.1:43124' }, + sidecar: replacementSidecar, + })) + const term = registry.create({ + mode: 'codex', + resumeSessionId: 'thread-1', + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar: currentSidecar, + recovery: { planCreate, retryDelayMs: 0 }, + }, + } as any, + }) + await store.write({ + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + terminalId: term.terminalId, + serverInstanceId: 'srv-test', + state: 'durable', + durableThreadId: 'thread-1', + updatedAt: 123, + }) + + currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledTimes(1)) + const replacementPty = mockPtyProcess.instances[1] + expect(registry.get(term.terminalId)?.pty).toBe(replacementPty) + + registry.get(term.terminalId)!.codexRecovery = undefined + replacementPty._emitExit(17) + + await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) + await vi.waitFor(async () => { + await expect(store.read(term.terminalId)).resolves.toBeUndefined() + }) + } finally { + await fsp.rm(durabilityDir, { recursive: true, force: true }) + } + }) + it('waits for a failed recovery candidate to shut down before retrying', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() const firstShutdown = deferred() const firstCandidate = createFakeSidecar({ - waitForLoadedThread: async () => { - throw new Error('candidate not ready') + adopt: async () => { + mockPtyProcess.instances[1]._emitExit(42) }, shutdown: () => firstShutdown.promise, }) @@ -598,7 +1736,7 @@ describe('TerminalRegistry Codex sidecar ownership', () => { await vi.waitFor(() => expect(secondCandidate.adopt).toHaveBeenCalled()) }) - it('does not grow active recovery candidates across repeated readiness failures', async () => { + it('does not grow active recovery candidates across repeated recovery candidate exits', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() let activeCandidates = 0 @@ -611,9 +1749,10 @@ describe('TerminalRegistry Codex sidecar ownership', () => { sessionId: 'thread-1', remote: { wsUrl: `ws://127.0.0.1:${43124 + attempt}` }, sidecar: createFakeSidecar({ - waitForLoadedThread: async () => { - if (attempt >= 3) return - throw new Error('candidate not ready') + adopt: async () => { + if (attempt < 3) { + mockPtyProcess.instances[attempt]._emitExit(42) + } }, shutdown: async () => { activeCandidates -= 1 @@ -676,10 +1815,10 @@ describe('TerminalRegistry Codex sidecar ownership', () => { it('final close with an unpublished recovery candidate awaits candidate shutdown', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() - const readiness = deferred() + const adopt = deferred() const shutdown = deferred() const replacementSidecar = createFakeSidecar({ - waitForLoadedThread: () => readiness.promise, + adopt: () => adopt.promise, shutdown: () => shutdown.promise, }) const planCreate = vi.fn(async () => ({ @@ -700,9 +1839,9 @@ describe('TerminalRegistry Codex sidecar ownership', () => { }) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(replacementSidecar.waitForLoadedThread).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledTimes(1)) const close = registry.killAndWait(term.terminalId) - readiness.resolve() + adopt.resolve() await vi.waitFor(() => expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(1)) let closed = false @@ -859,10 +1998,10 @@ describe('TerminalRegistry Codex sidecar ownership', () => { it('awaits recovery candidate teardown for exited Codex terminals while shutting down other running terminals', async () => { const registry = new TerminalRegistry() const currentSidecar = createFakeSidecar() - const readiness = deferred() + const adopt = deferred() const candidateShutdown = deferred() const replacementSidecar = createFakeSidecar({ - waitForLoadedThread: () => readiness.promise, + adopt: () => adopt.promise, shutdown: () => candidateShutdown.promise, }) const planCreate = vi.fn(async () => ({ @@ -883,9 +2022,9 @@ describe('TerminalRegistry Codex sidecar ownership', () => { }) currentSidecar.emitLifecycleLoss({ method: 'thread/closed', threadId: 'thread-1' }) - await vi.waitFor(() => expect(replacementSidecar.waitForLoadedThread).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(replacementSidecar.adopt).toHaveBeenCalledTimes(1)) registry.kill(codexTerm.terminalId) - readiness.resolve() + adopt.resolve() await vi.waitFor(() => expect(replacementSidecar.shutdown).toHaveBeenCalledTimes(1)) registry.create({ mode: 'shell' }) diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index ba2b915df..470d5745d 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -4,16 +4,9 @@ import { isValidClaudeSessionId } from '../../../server/claude-session-id' import * as fs from 'fs' import os from 'os' import { - CODEX_STARTUP_EXPECTED_REPLIES, CODEX_STARTUP_QUERY_FRAMES, } from '../../helpers/codex-startup-probes' -const SERVER_PREATTACH_CODEX_STARTUP_EXPECTED_REPLIES = [ - CODEX_STARTUP_EXPECTED_REPLIES[0], - CODEX_STARTUP_EXPECTED_REPLIES[1], - '\u001b]10;rgb:c9c9/d1d1/d9d9\u001b\\', -] as const - // Mock fs.existsSync for shell existence checks // Need to provide both named export and default export since the implementation uses `import fs from 'fs'` vi.mock('fs', () => { @@ -79,13 +72,12 @@ vi.mock('../../../server/mcp/config-writer.js', () => ({ })) const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' -const OTHER_CLAUDE_SESSION_ID = '6f1c2b3a-4d5e-6f70-8a9b-0c1d2e3f4a5b' +const OTHER_CLAUDE_SESSION_ID = '6f1c2b3a-4d5e-4f70-8a9b-0c1d2e3f4a5b' const TEST_OPENCODE_SERVER = { hostname: '127.0.0.1' as const, port: 4173 } function expectCodexMcpArgs(args: string[]) { - // Bell notification still present - expect(args).toContain('-c') - expect(args).toContain('tui.notification_method=bel') + expect(args).not.toContain('tui.notification_method=bel') + expect(args).not.toContain("tui.notifications=['agent-turn-complete']") // MCP server config instead of skills.config const mcpArg = args.find(a => a.includes('mcp_servers.freshell')) expect(mcpArg).toBeDefined() @@ -885,6 +877,34 @@ describe('buildSpawnSpec Unix paths', () => { expectCodexMcpArgs(spec.args) expect(spec.args.slice(-2)).toEqual(['resume', 'session-123']) }) + + it('disables Codex apps for Freshell-managed remote launches', () => { + delete process.env.CODEX_CMD + + const spec = buildSpawnSpec('codex', '/home/user/project', 'system', undefined, { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:4567', + }, + }) + + expect(spec.args.slice(0, 4)).toEqual([ + '--remote', + 'ws://127.0.0.1:4567', + '-c', + 'features.apps=false', + ]) + expectCodexMcpArgs(spec.args) + }) + + it('does not disable Codex apps for ordinary Codex launches', () => { + delete process.env.CODEX_CMD + + const spec = buildSpawnSpec('codex', '/home/user/project', 'system') + + expect(spec.args).not.toContain('features.apps=false') + expect(spec.args).not.toContain('--remote') + expectCodexMcpArgs(spec.args) + }) }) describe('provider settings in spawn spec', () => { @@ -990,6 +1010,38 @@ describe('buildSpawnSpec Unix paths', () => { expect(spec.args).toContain('openai/gpt-5-mini') }) + it('does not pass a default OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + model: 'abc', + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('abc') + }) + + it('does not pass an inferred OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY + delete process.env.GOOGLE_API_KEY + delete process.env.OPENAI_API_KEY + delete process.env.ANTHROPIC_API_KEY + process.env.GEMINI_API_KEY = 'gemini-key' + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('google/gemini-3-pro-preview') + }) + it('defaults OpenCode to a usable Google model and alias env when only GEMINI_API_KEY is set', () => { delete process.env.OPENCODE_CMD delete process.env.GOOGLE_GENERATIVE_AI_API_KEY @@ -1028,6 +1080,19 @@ describe('buildSpawnSpec Unix paths', () => { expect(spec.env.OPENCODE_PERMISSION).toBe('{"edit":"allow","bash":"ask"}') }) + + it('scrubs inherited OpenCode server auth env for managed TUI endpoints', () => { + delete process.env.OPENCODE_CMD + process.env.OPENCODE_SERVER_USERNAME = 'user' + process.env.OPENCODE_SERVER_PASSWORD = 'secret' + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', undefined, { + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.env).not.toHaveProperty('OPENCODE_SERVER_USERNAME') + expect(spec.env).not.toHaveProperty('OPENCODE_SERVER_PASSWORD') + }) }) describe('environment variables in spawn spec', () => { @@ -1722,8 +1787,8 @@ describe('buildSpawnSpec resume validation on Windows shells', () => { expect(spec.args).toContain('-NoExit') expect(spec.args[3]).toContain("& 'C:\\Program Files\\Codex\\codex.cmd'") expect(spec.args[3]).toContain("'-c'") - expect(spec.args[3]).toContain("'tui.notification_method=bel'") - expect(spec.args[3]).toContain("'tui.notifications=[''agent-turn-complete'']'") + expect(spec.args[3]).not.toContain("'tui.notification_method=bel'") + expect(spec.args[3]).not.toContain("'tui.notifications=[''agent-turn-complete'']'") expect(spec.args[3]).toContain("'resume'") expect(spec.args[3]).toContain("'session-123'") }) @@ -1967,6 +2032,29 @@ describe('TerminalRegistry', () => { expect(terminals).toHaveLength(1) expect(terminals[0].resumeSessionId).toBeUndefined() }) + + it('exposes a Codex sessionRef for an explicit durable resume', () => { + const created = registry.create({ + mode: 'codex', + cwd: '/home/user/project', + resumeSessionId: 'thread-proved-resume', + }) + + expect(registry.list()[0]).toMatchObject({ + resumeSessionId: 'thread-proved-resume', + sessionRef: { + provider: 'codex', + sessionId: 'thread-proved-resume', + }, + codexDurability: { + state: 'durable', + durableThreadId: 'thread-proved-resume', + }, + }) + + const record = registry.get(created.terminalId)! + expect(record.codexInputGate).toBeUndefined() + }) }) describe('list() returns mode', () => { @@ -2538,59 +2626,64 @@ describe('TerminalRegistry', () => { return { promise, resolve, reject } } - it('registers the terminal before a synchronous durable-session callback fires', () => { - let terminalSeenDuringAttach: string | undefined + function createSidecar(overrides: Partial<{ + shutdown: () => Promise<void> + onLifecycleLoss: (handler: (event: unknown) => void) => () => void + }> = {}) { + return { + adopt: vi.fn().mockResolvedValue(undefined), + markCandidatePersisted: vi.fn(), + shutdown: vi.fn(overrides.shutdown ?? (async () => undefined)), + onLifecycleLoss: vi.fn(overrides.onLifecycleLoss ?? (() => vi.fn())), + } + } + it('registers the current Codex sidecar when the terminal is created', () => { + const sidecar = createSidecar() const term = registry.create({ mode: 'codex', cwd: '/home/user/project', - codexSidecar: { - attachTerminal: ({ terminalId, onDurableSession }) => { - terminalSeenDuringAttach = registry.get(terminalId)?.terminalId - onDurableSession('codex-session-sync') + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, }, - shutdown: vi.fn().mockResolvedValue(undefined), - }, + } as any, }) - expect(terminalSeenDuringAttach).toBe(term.terminalId) - expect(registry.get(term.terminalId)?.resumeSessionId).toBe('codex-session-sync') - expect(registry.isSessionBound('codex', 'codex-session-sync')).toBe(true) + expect(sidecar.onLifecycleLoss).toHaveBeenCalledTimes(1) + expect(registry.get(term.terminalId)).toBe(term) }) - it('keeps the newly created terminal alive when a synchronous fatal callback starts recovery', () => { - let createdTerminalId: string | undefined - const exited = vi.fn() - registry.on('terminal.exit', exited) - + it('does not treat sidecar registration as durable identity', () => { + const sidecar = createSidecar() const term = registry.create({ mode: 'codex', cwd: '/home/user/project', - codexSidecar: { - attachTerminal: ({ terminalId, onFatal }) => { - createdTerminalId = terminalId - onFatal(new Error('sidecar failed during attach')) + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, }, - shutdown: vi.fn().mockResolvedValue(undefined), - }, + } as any, }) - expect(createdTerminalId).toBe(term.terminalId) - expect(registry.get(term.terminalId)?.status).toBe('running') - expect(registry.get(term.terminalId)?.codex?.recoveryState).toBe('recovering_pre_durable') - expect(exited).not.toHaveBeenCalled() + expect(registry.get(term.terminalId)?.resumeSessionId).toBeUndefined() + expect(registry.isSessionBound('codex', 'codex-session-sync')).toBe(false) }) it('waits for pending Codex sidecar shutdown work during graceful shutdown', async () => { const sidecarShutdown = deferred() - const sidecar = { - attachTerminal: vi.fn(), - shutdown: vi.fn(() => sidecarShutdown.promise), - } + const sidecar = createSidecar({ shutdown: () => sidecarShutdown.promise }) const term = registry.create({ mode: 'codex', cwd: '/home/user/project', - codexSidecar: sidecar, + providerSettings: { + codexAppServer: { + wsUrl: 'ws://127.0.0.1:43123', + sidecar, + }, + } as any, }) registry.kill(term.terminalId) @@ -2612,7 +2705,7 @@ describe('TerminalRegistry', () => { }) describe('pre-attach codex startup probes', () => { - it('answers codex startup probes before the first client attaches', async () => { + it('leaves Codex startup probe replies to the client-side terminal parser before first attach', async () => { registry.create({ mode: 'codex', cwd: '/home/user/project', @@ -2624,7 +2717,7 @@ describe('TerminalRegistry', () => { onDataCallback(CODEX_STARTUP_QUERY_FRAMES.join('')) - expect(mockPty.write.mock.calls.map(([data]: [string]) => data)).toEqual(SERVER_PREATTACH_CODEX_STARTUP_EXPECTED_REPLIES) + expect(mockPty.write).not.toHaveBeenCalled() }) it('stops server-side startup probe replies after a client has attached once', async () => { diff --git a/test/unit/server/updater/index.test.ts b/test/unit/server/updater/index.test.ts index 2540cd1bb..92cbaa0dd 100644 --- a/test/unit/server/updater/index.test.ts +++ b/test/unit/server/updater/index.test.ts @@ -343,7 +343,8 @@ describe('update orchestrator', () => { it('returns false by default when no skip conditions are met', () => { const result = shouldSkipUpdateCheck({ argv: ['node', 'script.js'], - env: { npm_lifecycle_event: 'preserve' } + env: { npm_lifecycle_event: 'preserve' }, + branch: 'main', }) expect(result).toBe(false) }) @@ -367,7 +368,8 @@ describe('update orchestrator', () => { it('returns false when SKIP_UPDATE_CHECK env var is other value', () => { const result = shouldSkipUpdateCheck({ argv: ['node', 'script.js'], - env: { SKIP_UPDATE_CHECK: 'false' } + env: { SKIP_UPDATE_CHECK: 'false' }, + branch: 'main', }) expect(result).toBe(false) }) @@ -383,7 +385,8 @@ describe('update orchestrator', () => { it('returns false when npm_lifecycle_event is "preserve"', () => { const result = shouldSkipUpdateCheck({ argv: ['node', 'script.js'], - env: { npm_lifecycle_event: 'preserve' } + env: { npm_lifecycle_event: 'preserve' }, + branch: 'main', }) expect(result).toBe(false) }) @@ -392,11 +395,52 @@ describe('update orchestrator', () => { // This is the key behavior change - NODE_ENV should not affect the check const result = shouldSkipUpdateCheck({ argv: ['node', 'script.js'], - env: { NODE_ENV: 'development', npm_lifecycle_event: 'preserve' } + env: { NODE_ENV: 'development', npm_lifecycle_event: 'preserve' }, + branch: 'main', }) expect(result).toBe(false) }) + it('skips update checks on dev branch', () => { + const result = shouldSkipUpdateCheck({ + argv: ['node', 'script.js'], + env: {}, + branch: 'dev', + }) + + expect(result).toBe(true) + }) + + it('skips update checks on feature branches', () => { + const result = shouldSkipUpdateCheck({ + argv: ['node', 'script.js'], + env: {}, + branch: 'feature/x', + }) + + expect(result).toBe(true) + }) + + it('does not skip update checks on main branch by branch policy alone', () => { + const result = shouldSkipUpdateCheck({ + argv: ['node', 'script.js'], + env: {}, + branch: 'main', + }) + + expect(result).toBe(false) + }) + + it('skips update checks when branch detection fails', () => { + const result = shouldSkipUpdateCheck({ + argv: ['node', 'script.js'], + env: {}, + branch: undefined, + }) + + expect(result).toBe(true) + }) + it('uses process.argv and process.env by default', () => { // When called with no options, it should use the actual process values // Just verify it returns a boolean without throwing diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts new file mode 100644 index 000000000..346beef2f --- /dev/null +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -0,0 +1,537 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from 'http' +import WebSocket from 'ws' + +import { WsHandler } from '../../../server/ws-handler.js' +import { TerminalRegistry } from '../../../server/terminal-registry.js' +import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol.js' + +const TEST_AUTH_TOKEN = 'testtoken-testtoken' + +describe('WsHandler fresh-agent routing', () => { + let originalAuthToken: string | undefined + + beforeEach(() => { + originalAuthToken = process.env.AUTH_TOKEN + process.env.AUTH_TOKEN = TEST_AUTH_TOKEN + }) + + async function createServer(options: Record<string, unknown> = {}) { + const server = http.createServer() + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const registry = new TerminalRegistry() + const handler = new WsHandler(server, registry, options as any) + return { server, registry, handler } + } + + async function connectAndAuth(server: http.Server) { + const addr = server.address() + const port = typeof addr === 'object' ? addr!.port : 0 + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise<void>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout waiting for ready')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ + type: 'hello', + token: TEST_AUTH_TOKEN, + protocolVersion: WS_PROTOCOL_VERSION, + })) + }) + ws.on('message', (data) => { + const message = JSON.parse(data.toString()) + if (message.type === 'ready') { + clearTimeout(timeout) + resolve() + } + }) + ws.on('error', reject) + }) + return ws + } + + it('routes freshAgent.create through the runtime manager while terminal traffic remains unchanged', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-1', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/workspace', + })) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId: 'term-1', + mode: 'shell', + })) + + await vi.waitFor(() => { + expect(runtimeManager.create).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'freshcodex', + provider: 'codex', + })) + expect(seenMessages.some((message) => message.type === 'freshAgent.created')).toBe(true) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('replays duplicate freshAgent.create request ids without creating duplicate runtime sessions', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-idempotent', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + const createMessage = { + type: 'freshAgent.create', + requestId: 'req-idempotent', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/workspace', + } + + ws.send(JSON.stringify(createMessage)) + await vi.waitFor(() => { + expect(seenMessages.filter((message) => message.type === 'freshAgent.created')).toHaveLength(1) + }) + + ws.send(JSON.stringify(createMessage)) + + await vi.waitFor(() => { + const created = seenMessages.filter((message) => message.type === 'freshAgent.created') + expect(created).toHaveLength(2) + expect(created).toEqual([ + expect.objectContaining({ + requestId: 'req-idempotent', + sessionId: 'codex-session-idempotent', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + }), + expect.objectContaining({ + requestId: 'req-idempotent', + sessionId: 'codex-session-idempotent', + sessionType: 'freshcodex', + provider: 'codex', + runtimeProvider: 'codex', + }), + ]) + expect(runtimeManager.create).toHaveBeenCalledTimes(1) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('clears fresh-agent create replay entries when the session is killed and when the handler closes', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-cache-cleanup', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + kill: vi.fn().mockResolvedValue(true), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-cache-cleanup', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/workspace', + })) + + await vi.waitFor(() => { + expect(seenMessages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.created', + requestId: 'req-cache-cleanup', + sessionId: 'codex-session-cache-cleanup', + })) + }) + expect((handler as any).createdFreshAgentByRequestId.size).toBe(1) + + ws.send(JSON.stringify({ + type: 'freshAgent.kill', + sessionId: 'codex-session-cache-cleanup', + sessionType: 'freshcodex', + provider: 'codex', + })) + + await vi.waitFor(() => { + expect(runtimeManager.kill).toHaveBeenCalledWith({ + sessionId: 'codex-session-cache-cleanup', + sessionType: 'freshcodex', + provider: 'codex', + }) + expect((handler as any).createdFreshAgentByRequestId.size).toBe(0) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-cache-close', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/workspace', + })) + + await vi.waitFor(() => { + expect((handler as any).createdFreshAgentByRequestId.size).toBe(1) + }) + + handler.close() + expect((handler as any).createdFreshAgentByRequestId.size).toBe(0) + expect((handler as any).freshAgentCreateLocks.size).toBe(0) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('routes freshAgent.send, freshAgent.interrupt, freshAgent approvals/questions, freshAgent.kill, and freshAgent.fork through the runtime manager after create ownership is established', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue(undefined), + interrupt: vi.fn().mockResolvedValue(undefined), + resolveApproval: vi.fn().mockResolvedValue(undefined), + answerQuestion: vi.fn().mockResolvedValue(undefined), + kill: vi.fn().mockResolvedValue(true), + fork: vi.fn().mockResolvedValue({ sessionId: 'forked-session' }), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-2', + sessionType: 'freshcodex', + provider: 'codex', + })) + + await vi.waitFor(() => { + expect(runtimeManager.create).toHaveBeenCalled() + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Ship it', + })) + ws.send(JSON.stringify({ + type: 'freshAgent.interrupt', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + })) + ws.send(JSON.stringify({ + type: 'freshAgent.approval.respond', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + requestId: 'approval-1', + decision: { behavior: 'allow', updatedInput: {} }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.question.respond', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + requestId: 'question-1', + answers: { proceed: 'yes' }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.fork', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + })) + ws.send(JSON.stringify({ + type: 'freshAgent.kill', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', + })) + + await vi.waitFor(() => { + const locator = { sessionId: 'codex-session-2', sessionType: 'freshcodex', provider: 'codex' } + expect(runtimeManager.send).toHaveBeenCalledWith(locator, { text: 'Ship it', images: undefined }) + expect(runtimeManager.interrupt).toHaveBeenCalledWith(locator) + expect(runtimeManager.resolveApproval).toHaveBeenCalledWith(locator, 'approval-1', { behavior: 'allow', updatedInput: {} }) + expect(runtimeManager.answerQuestion).toHaveBeenCalledWith(locator, 'question-1', { proceed: 'yes' }) + expect(runtimeManager.fork).toHaveBeenCalledWith(locator, undefined) + expect(runtimeManager.kill).toHaveBeenCalledWith(locator) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('attaches a persisted fresh-agent session and forwards live adapter events over freshAgent.event', async () => { + const listeners = new Map<string, (message: unknown) => void>() + const runtimeManager = { + attach: vi.fn().mockReturnValue({ + sessionId: 'claude-session-attached', + sessionType: 'freshclaude', + runtimeProvider: 'claude', + }), + subscribe: vi.fn().mockImplementation(async (locator: unknown, listener: (message: unknown) => void) => { + listeners.set(JSON.stringify(locator), listener) + return () => { + listeners.delete(JSON.stringify(locator)) + } + }), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'claude-session-attached', + sessionType: 'freshclaude', + provider: 'claude', + resumeSessionId: 'cli-session-attached', + })) + + await vi.waitFor(() => { + expect(runtimeManager.attach).toHaveBeenCalledWith({ + sessionId: 'claude-session-attached', + sessionType: 'freshclaude', + provider: 'claude', + }) + expect(runtimeManager.subscribe).toHaveBeenCalledWith( + { sessionId: 'claude-session-attached', sessionType: 'freshclaude', provider: 'claude' }, + expect.any(Function), + ) + }) + + listeners.get(JSON.stringify({ sessionId: 'claude-session-attached', sessionType: 'freshclaude', provider: 'claude' }))?.({ kind: 'thread.updated', revision: 2 }) + + await vi.waitFor(() => { + expect(seenMessages).toContainEqual({ + type: 'freshAgent.event', + sessionId: 'claude-session-attached', + sessionType: 'freshclaude', + provider: 'claude', + event: { kind: 'thread.updated', revision: 2 }, + }) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('reports fresh-agent subscription failures instead of silently dropping live updates', async () => { + const runtimeManager = { + attach: vi.fn().mockReturnValue({ + sessionId: 'codex-session-no-subscribe', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockRejectedValue(new Error('Codex app-server lifecycle subscription failed')), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'codex-session-no-subscribe', + sessionType: 'freshcodex', + provider: 'codex', + })) + + await vi.waitFor(() => { + expect(runtimeManager.subscribe).toHaveBeenCalledWith( + { sessionId: 'codex-session-no-subscribe', sessionType: 'freshcodex', provider: 'codex' }, + expect.any(Function), + ) + expect(seenMessages).toContainEqual({ + type: 'freshAgent.event', + sessionId: 'codex-session-no-subscribe', + sessionType: 'freshcodex', + provider: 'codex', + event: { + type: 'sdk.error', + sessionId: 'codex-session-no-subscribe', + code: 'FRESH_AGENT_SUBSCRIBE_FAILED', + message: 'Codex app-server lifecycle subscription failed', + }, + }) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('unsubscribes a late fresh-agent subscription when the client clears the session before subscribe resolves', async () => { + let resolveSubscribe!: (off: () => void) => void + const off = vi.fn() + const runtimeManager = { + attach: vi.fn().mockReturnValue({ + sessionId: 'claude-session-race', + sessionType: 'freshclaude', + runtimeProvider: 'claude', + }), + subscribe: vi.fn().mockImplementation(async () => ( + await new Promise<() => void>((resolve) => { + resolveSubscribe = resolve + }) + )), + kill: vi.fn().mockResolvedValue(true), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'claude-session-race', + sessionType: 'freshclaude', + provider: 'claude', + })) + + await vi.waitFor(() => { + expect(runtimeManager.subscribe).toHaveBeenCalled() + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.kill', + sessionId: 'claude-session-race', + sessionType: 'freshclaude', + provider: 'claude', + })) + + await vi.waitFor(() => { + expect(runtimeManager.kill).toHaveBeenCalledWith({ + sessionId: 'claude-session-race', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) + + resolveSubscribe(off) + + await vi.waitFor(() => { + expect(off).toHaveBeenCalledTimes(1) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) + + it('deduplicates concurrent fresh-agent subscription registration while subscribe is pending', async () => { + let resolveSubscribe!: (off: () => void) => void + const off = vi.fn() + const runtimeManager = { + attach: vi.fn().mockReturnValue({ + sessionId: 'codex-session-pending-subscribe', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockImplementation(async () => ( + await new Promise<() => void>((resolve) => { + resolveSubscribe = resolve + }) + )), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const attachMessage = { + type: 'freshAgent.attach', + sessionId: 'codex-session-pending-subscribe', + sessionType: 'freshcodex', + provider: 'codex', + } + + ws.send(JSON.stringify(attachMessage)) + ws.send(JSON.stringify(attachMessage)) + + await vi.waitFor(() => { + expect(runtimeManager.attach).toHaveBeenCalledTimes(2) + expect(runtimeManager.subscribe).toHaveBeenCalledTimes(1) + }) + + resolveSubscribe(off) + + await vi.waitFor(() => { + expect(off).not.toHaveBeenCalled() + }) + } finally { + handler.close() + registry.shutdown() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) +}) diff --git a/test/unit/server/ws-sdk-session-history-cache.test.ts b/test/unit/server/ws-sdk-session-history-cache.test.ts index 9d6e46c1d..b7fe32eb9 100644 --- a/test/unit/server/ws-sdk-session-history-cache.test.ts +++ b/test/unit/server/ws-sdk-session-history-cache.test.ts @@ -377,7 +377,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', pendingPermissions: new Map(), @@ -394,7 +394,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', })), @@ -423,12 +423,12 @@ describe('WsHandler agent history source DI', () => { type: 'sdk.create', requestId: 'req-module', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', })) await waitForMessage(ws, (d) => d.type === 'sdk.session.snapshot') - expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-cdef-0123-456789abcdef') + expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-4def-8123-456789abcdef') ws.close() }) diff --git a/test/unit/shared/fresh-agent-contract-traceability.test.ts b/test/unit/shared/fresh-agent-contract-traceability.test.ts new file mode 100644 index 000000000..438316017 --- /dev/null +++ b/test/unit/shared/fresh-agent-contract-traceability.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { FRESH_AGENT_CONTRACT_SCHEMA_NAMES } from '../../../shared/fresh-agent-contract.js' +import { FRESH_AGENT_CONTRACT_TRACEABILITY } from '../../fixtures/fresh-agent/contract-traceability.js' + +describe('fresh-agent contract traceability', () => { + it('assigns every shared schema to producers, parsers, state, UI, fixtures, and tests', () => { + expect(FRESH_AGENT_CONTRACT_TRACEABILITY.map((entry) => entry.schema).sort()).toEqual( + [...FRESH_AGENT_CONTRACT_SCHEMA_NAMES].sort(), + ) + + for (const entry of FRESH_AGENT_CONTRACT_TRACEABILITY) { + expect(entry.producers.length, `${entry.schema} producers`).toBeGreaterThan(0) + expect(entry.serverParser, `${entry.schema} serverParser`).toMatch(/\S/) + expect(entry.clientParser, `${entry.schema} clientParser`).toMatch(/\S/) + expect(entry.stateOwner, `${entry.schema} stateOwner`).toMatch(/\S/) + expect(entry.uiConsumer, `${entry.schema} uiConsumer`).toMatch(/\S/) + expect(entry.fixtures.length, `${entry.schema} fixtures`).toBeGreaterThan(0) + expect(entry.tests.length, `${entry.schema} tests`).toBeGreaterThan(0) + } + }) +}) diff --git a/test/unit/shared/fresh-agent-contract.test.ts b/test/unit/shared/fresh-agent-contract.test.ts new file mode 100644 index 000000000..4d6e5825e --- /dev/null +++ b/test/unit/shared/fresh-agent-contract.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { + FreshAgentActionResultSchema, + FreshAgentContractErrorSchema, + FreshAgentRequestIdSchema, + FreshAgentSnapshotSchema, + FreshAgentTurnBodySchema, + FreshAgentTurnPageSchema, +} from '../../../shared/fresh-agent-contract.js' +import { + claudeContractSnapshot, + claudeContractTurnBody, + claudeContractTurnPage, +} from '../../fixtures/fresh-agent/claude/contract-fixtures.js' +import { + codexContractSnapshot, + codexContractTurnBody, + codexContractTurnPage, +} from '../../fixtures/fresh-agent/codex/contract-fixtures.js' + +describe('fresh-agent shared contract schemas', () => { + it('parses Claude and Codex snapshots through one shared durable contract', () => { + expect(FreshAgentSnapshotSchema.parse(claudeContractSnapshot).sessionType).toBe('freshclaude') + expect(FreshAgentSnapshotSchema.parse(codexContractSnapshot).sessionType).toBe('freshcodex') + }) + + it('parses turn pages and turn bodies with the full session locator', () => { + expect(FreshAgentTurnPageSchema.parse(claudeContractTurnPage).provider).toBe('claude') + expect(FreshAgentTurnPageSchema.parse(codexContractTurnPage).provider).toBe('codex') + expect(FreshAgentTurnBodySchema.parse(claudeContractTurnBody).threadId).toBe('sdk-claude-1') + expect(FreshAgentTurnBodySchema.parse(codexContractTurnBody).threadId).toBe('thread-codex-1') + }) + + it('keeps Codex server request ids as string or integer values', () => { + expect(FreshAgentRequestIdSchema.parse('request-1')).toBe('request-1') + expect(FreshAgentRequestIdSchema.parse(42)).toBe(42) + expect(() => FreshAgentRequestIdSchema.parse(1.25)).toThrow() + }) + + it('rejects provider blobs that bypass the typed extension boundary', () => { + expect(() => FreshAgentSnapshotSchema.parse({ + ...codexContractSnapshot, + extensions: { codex: { review: { id: 'review-1' } }, extraProvider: {} }, + })).toThrow() + }) + + it('parses action results and contract errors with locator context', () => { + expect(FreshAgentActionResultSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + action: 'fork', + result: { threadId: 'thread-child-1' }, + }).action).toBe('fork') + + expect(FreshAgentContractErrorSchema.parse({ + code: 'FRESH_AGENT_CONTRACT_PARSE_FAILED', + message: 'Invalid snapshot', + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-codex-1', + }).code).toBe('FRESH_AGENT_CONTRACT_PARSE_FAILED') + }) +}) diff --git a/test/unit/shared/fresh-agent-registry.test.ts b/test/unit/shared/fresh-agent-registry.test.ts new file mode 100644 index 000000000..a1d00de1b --- /dev/null +++ b/test/unit/shared/fresh-agent-registry.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' + +describe('fresh-agent registry', () => { + it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { + expect(resolveFreshAgentType('kilroy')).toMatchObject({ + runtimeProvider: 'claude', + hidden: true, + }) + }) + + it('registers freshcodex as a codex-backed session type', () => { + expect(resolveFreshAgentType('freshcodex')).toMatchObject({ + runtimeProvider: 'codex', + label: 'Freshcodex', + }) + }) +}) diff --git a/test/unit/shared/selfhost-branch-policy.test.ts b/test/unit/shared/selfhost-branch-policy.test.ts new file mode 100644 index 000000000..ec2d68cb8 --- /dev/null +++ b/test/unit/shared/selfhost-branch-policy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + classifySelfHostBranch, + getExpectedSelfHostBranch, + shouldSkipSourceUpdateForBranch, +} from '../../../shared/selfhost-branch-policy.js' + +describe('selfhost branch policy', () => { + it('defaults the expected self-host branch to dev', () => { + expect(getExpectedSelfHostBranch({})).toBe('dev') + }) + + it('allows overriding the expected self-host branch', () => { + expect(getExpectedSelfHostBranch({ FRESHELL_SELFHOST_BRANCH: 'dev/pr-queue' })).toBe('dev/pr-queue') + }) + + it('rejects self-host launch from main', () => { + expect(classifySelfHostBranch({ branch: 'main', env: {} })).toEqual({ + ok: false, + code: 'mirror-branch', + message: "Refusing to self-host from local 'main'. Local 'main' must mirror 'origin/main'. Switch to 'dev' or set FRESHELL_SELFHOST_BRANCH.", + }) + }) + + it('rejects self-host launch from main even if configured by env', () => { + expect(classifySelfHostBranch({ + branch: 'main', + env: { FRESHELL_SELFHOST_BRANCH: 'main' }, + })).toMatchObject({ + ok: false, + code: 'mirror-branch', + }) + }) + + it('accepts self-host launch from dev by default', () => { + expect(classifySelfHostBranch({ branch: 'dev', env: {} })).toEqual({ ok: true, expectedBranch: 'dev' }) + }) + + it('rejects unexpected non-main branches unless they are configured', () => { + expect(classifySelfHostBranch({ branch: 'feature/x', env: {} })).toMatchObject({ + ok: false, + code: 'unexpected-branch', + }) + }) + + it('accepts a configured non-main self-host branch', () => { + expect(classifySelfHostBranch({ + branch: 'dev/pr-queue', + env: { FRESHELL_SELFHOST_BRANCH: 'dev/pr-queue' }, + })).toEqual({ ok: true, expectedBranch: 'dev/pr-queue' }) + }) + + it('skips source updates on dev and feature branches', () => { + expect(shouldSkipSourceUpdateForBranch({ branch: 'dev', env: {} })).toBe(true) + expect(shouldSkipSourceUpdateForBranch({ branch: 'feature/x', env: {} })).toBe(true) + }) + + it('does not skip source updates on main unless another skip condition applies', () => { + expect(shouldSkipSourceUpdateForBranch({ branch: 'main', env: {} })).toBe(false) + }) + + it('skips source updates when the explicit skip env var is set', () => { + expect(shouldSkipSourceUpdateForBranch({ + branch: 'main', + env: { SKIP_UPDATE_CHECK: 'true' }, + })).toBe(true) + }) + + it('skips source updates when branch detection fails', () => { + expect(shouldSkipSourceUpdateForBranch({ branch: undefined, env: {} })).toBe(true) + }) +}) diff --git a/test/unit/shared/session-contract.test.ts b/test/unit/shared/session-contract.test.ts index 573449bbe..3787a09ba 100644 --- a/test/unit/shared/session-contract.test.ts +++ b/test/unit/shared/session-contract.test.ts @@ -90,9 +90,20 @@ describe('session-contract', () => { }) }) - it('marks legacy raw agent-chat resume ids as restore-unavailable when no canonical Claude id exists', () => { + it('promotes canonical Claude legacy resume ids for agent-chat panes', () => { expect(migrateLegacyAgentChatDurableState({ resumeSessionId: VALID_CLAUDE_SESSION_ID, + })).toEqual({ + sessionRef: { + provider: 'claude', + sessionId: VALID_CLAUDE_SESSION_ID, + }, + }) + }) + + it('marks legacy raw agent-chat resume ids as restore-unavailable when no canonical Claude id exists', () => { + expect(migrateLegacyAgentChatDurableState({ + resumeSessionId: 'named-resume', })).toEqual({ restoreError: { code: 'RESTORE_UNAVAILABLE', diff --git a/test/unit/shared/settings.test.ts b/test/unit/shared/settings.test.ts index 6ae7eeafc..9bb010be1 100644 --- a/test/unit/shared/settings.test.ts +++ b/test/unit/shared/settings.test.ts @@ -308,6 +308,51 @@ describe('shared settings contract', () => { agentChat: { defaultPlugins: ['fs'], }, + freshAgent: { + defaultPlugins: ['fs'], + }, + }) + }) + + it('defaults multirowTabs to false in resolved local settings', () => { + expect(resolveLocalSettings(undefined).panes.multirowTabs).toBe(false) + }) + + it('accepts multirowTabs boolean in local settings patch', () => { + const resolved = resolveLocalSettings({ panes: { multirowTabs: true } }) + expect(resolved.panes.multirowTabs).toBe(true) + }) + + it('preserves multirowTabs when extracting legacy local settings seed', () => { + expect(extractLegacyLocalSettingsSeed({ + panes: { + multirowTabs: true, + }, + } as Record<string, unknown>)).toEqual({ + panes: { + multirowTabs: true, + }, }) }) + + it('rejects non-boolean multirowTabs in legacy seed extraction', () => { + expect(extractLegacyLocalSettingsSeed({ + panes: { + multirowTabs: 'yes', + }, + } as Record<string, unknown>)).toEqual(undefined) + }) + + it('includes multirowTabs in composed resolved settings', () => { + const resolved = composeResolvedSettings( + createDefaultServerSettings({ loggingDebug: false }), + resolveLocalSettings({ panes: { multirowTabs: true } }), + ) + expect(resolved.panes.multirowTabs).toBe(true) + }) + + it('rejects multirowTabs in server patch schema', () => { + const schema = buildServerSettingsPatchSchema() + expect(schema.safeParse({ panes: { multirowTabs: true } }).success).toBe(false) + }) }) diff --git a/test/unit/vite-config.test.ts b/test/unit/vite-config.test.ts index ec5c800af..6080e3362 100644 --- a/test/unit/vite-config.test.ts +++ b/test/unit/vite-config.test.ts @@ -150,11 +150,19 @@ describe('vitest config', () => { } }) - it('does not exclude real-provider integration contracts from the default suite', async () => { + it('excludes real-provider integration contracts from the default jsdom suite', async () => { const configModule = await import('../../vitest.config.ts') const config = configModule.default const excluded = config.test?.exclude ?? [] - expect(excluded).not.toContain('test/integration/real/**') + expect(excluded).toContain('test/integration/real/**') + }) + + it('runs real-provider integration contracts in the node server suite', async () => { + const configModule = await import('../../vitest.server.config.ts') + const config = configModule.default + const included = config.test?.include ?? [] + + expect(included).toContain('test/integration/real/**/*.test.ts') }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 6c9a546a4..cf4e92982 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/e2e-browser/**', + 'test/integration/real/**', // Electron tests run under vitest.electron.config.ts (node environment) 'test/unit/electron/**', // Electron E2E tests run under Playwright, not Vitest diff --git a/vitest.server.config.ts b/vitest.server.config.ts index 8f065e954..8ca3d23be 100644 --- a/vitest.server.config.ts +++ b/vitest.server.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ 'test/unit/server/**/*.test.ts', 'test/unit/visible-first/**/*.test.ts', 'test/integration/server/**/*.test.ts', + 'test/integration/real/**/*.test.ts', 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/integration/extension-system.test.ts',