From b851be4359d3d5ad859ca71a67b227177919a9b8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 01:27:46 -0700 Subject: [PATCH 01/11] plan: opencode browser refresh restore --- ...-06-01-opencode-browser-refresh-restore.md | 1070 +++++++++++++++++ 1 file changed, 1070 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md diff --git a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md new file mode 100644 index 00000000..fb89a79b --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md @@ -0,0 +1,1070 @@ +# OpenCode Browser Refresh Restore Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OpenCode browser refresh restore survive a missed `terminal.session.associated` UI event by replaying canonical `sessionRef` through server terminal metadata and central client reconciliation. + +**Architecture:** Keep `terminal.session.associated` as the authoritative live promotion event, but make already-promoted terminal identity replayable through `terminal.inventory`, `terminal.created`, and `terminal.attach.ready`. Add one client-side reconciliation helper that persists `{ terminalId, sessionRef }` into matching terminal panes before stale live handles are cleared, and use it from App-level WebSocket handling so it works even when `TerminalView` is not mounted. Do not infer OpenCode identity from cwd, title, or the OpenCode database during refresh. + +**Tech Stack:** TypeScript, React 18, Redux Toolkit, WebSocket protocol types in `shared/ws-protocol.ts`, Vitest, Testing Library. + +--- + +## Context + +The theory file is `/tmp/freshell-opencode-restore-theory.md`. It identifies a refresh-time race where OpenCode panes can persist a live `terminalId` but miss the later canonical `sessionRef`. After refresh, Freshell can attach only while the same server still owns that `terminalId`; once the handle is dead, non-Codex restore correctly requires a canonical `sessionRef`. + +Existing durable identity contracts to preserve: + +- `sessionRef` is the canonical durable identity; `terminalId` is only a live server handle. +- OpenCode restore identity must come from server-side ownership proof and registry binding, not cwd/title/database guessing. +- `terminal.session.associated` remains the live promotion event. Inventory/create/attach metadata only replay a canonical identity that the server already knows. +- `sessionRef` may authorize a restored create after stale-handle cleanup. It must not be used as runtime kill/replacement authority for OpenCode replay-gap repair. +- The baseline full suite on `origin/main` was user-accepted as a flake despite one failure in `test/integration/real/coding-cli-session-contract.test.ts` waiting for an OpenCode DB row. + +## File Structure + +- Modify `shared/ws-protocol.ts` + - Add optional `sessionRef?: SessionLocator` to `TerminalCreatedMessage` and `TerminalAttachReadyMessage`. +- Create `server/terminal-session-ref.ts` + - Central server helper for exposing a terminal record's already-known canonical durable `sessionRef`. +- Modify `server/terminal-registry.ts` + - Reuse the helper in `list()` so inventory keeps the existing behavior with less duplicated logic. +- Modify `server/ws-handler.ts` + - Include `sessionRef` on `terminal.created` when the terminal record already has canonical durable identity. + - Include the same identity for reused existing terminals. +- Modify `server/terminal-stream/broker.ts` + - Include `sessionRef` on `terminal.attach.ready` when attaching to a terminal with canonical durable identity. +- Modify `src/store/panesSlice.ts` + - Add a reducer that reconciles a canonical session ref into every terminal pane matching a live `terminalId`, clears legacy `resumeSessionId`, and clears stale fresh-fallback attempts for those panes. +- Create `src/lib/terminal-session-association.ts` + - Shared client helper that finds matching panes, dispatches the panes reducer, updates tab-level fallback identity only for single-pane tabs, and flushes persisted layout. +- Modify `src/App.tsx` + - Use the client helper for `terminal.session.associated`, `terminal.created`, `terminal.attach.ready`, and inventory terminals before `clearDeadTerminals`. +- Modify `src/components/TerminalView.tsx` + - Remove duplicated session-association persistence logic and rely on the central helper; keep local terminal lifecycle behavior unchanged. + - On `terminal.created` and `terminal.attach.ready`, call the helper when those messages carry `sessionRef`. +- Test `test/server/ws-protocol.test.ts` + - Prove `terminal.created` and `terminal.attach.ready` replay OpenCode `sessionRef` for a restored OpenCode terminal. +- Test `test/unit/client/components/App.ws-bootstrap.test.tsx` + - Prove App-level inventory reconciliation recovers a stripped OpenCode `sessionRef` before clearing a stale handle. + - Prove App-level live association works without `TerminalView` mounted. +- Test `test/unit/client/components/TerminalView.resumeSession.test.tsx` + - Update expectations so `terminal.created` remains live-only when no `sessionRef` is present, and add a narrow assertion that a provided `sessionRef` is persisted through the central path. +- Test `test/e2e/terminal-restart-recovery.test.tsx` + - Prove a pane whose missing OpenCode `sessionRef` was recovered before stale-handle cleanup emits a restored `terminal.create`, not fresh fallback. + +## Task 1: Server Replayable SessionRef Contract + +**Files:** +- Modify: `shared/ws-protocol.ts` +- Create: `server/terminal-session-ref.ts` +- Modify: `server/terminal-registry.ts` +- Modify: `server/ws-handler.ts` +- Modify: `server/terminal-stream/broker.ts` +- Test: `test/server/ws-protocol.test.ts` + +- [ ] **Step 1: Write the failing server test** + +Add this test near the existing terminal create/attach protocol tests in `test/server/ws-protocol.test.ts`: + +```ts +it('replays OpenCode sessionRef on terminal.created and terminal.attach.ready for restored terminals', async () => { + const { ws, close } = await createAuthenticatedConnection() + const requestId = 'req-opencode-restored-session-ref' + const sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_browser_refresh_restore', + } + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'opencode', + restore: true, + sessionRef, + cwd: '/repo/project', + })) + + const created = await waitForMessage( + ws, + (msg) => msg.type === 'terminal.created' && msg.requestId === requestId, + 5000, + ) + + expect(created).toMatchObject({ + type: 'terminal.created', + requestId, + sessionRef, + }) + + ws.send(JSON.stringify({ + type: 'terminal.attach', + terminalId: created.terminalId, + intent: 'viewport_hydrate', + cols: 120, + rows: 40, + sinceSeq: 0, + })) + + const ready = await waitForMessage( + ws, + (msg) => msg.type === 'terminal.attach.ready' && msg.terminalId === created.terminalId, + 5000, + ) + + expect(ready).toMatchObject({ + type: 'terminal.attach.ready', + terminalId: created.terminalId, + sessionRef, + }) + + await close() +}) +``` + +- [ ] **Step 2: Run the server test and verify it fails** + +Run: + +```bash +npm run test:vitest -- test/server/ws-protocol.test.ts --run -t "replays OpenCode sessionRef" +``` + +Expected: FAIL because neither `terminal.created` nor `terminal.attach.ready` currently includes `sessionRef`. + +- [ ] **Step 3: Add server protocol fields** + +In `shared/ws-protocol.ts`, change the server message types: + +```ts +export type TerminalCreatedMessage = { + type: 'terminal.created' + requestId: string + terminalId: string + createdAt: number + sessionRef?: SessionLocator + clearCodexDurability?: boolean + restoreError?: RestoreError +} + +export type TerminalAttachReadyMessage = { + type: 'terminal.attach.ready' + terminalId: string + headSeq: number + replayFromSeq: number + replayToSeq: number + attachRequestId?: string + sessionRef?: SessionLocator +} +``` + +- [ ] **Step 4: Create the server sessionRef helper** + +Create `server/terminal-session-ref.ts`: + +```ts +import type { SessionLocator } from '../shared/ws-protocol.js' +import { modeSupportsResume, type TerminalMode, type TerminalRecord } from './terminal-registry.js' +import type { CodingCliProviderName } from './coding-cli/types.js' +import type { CodexDurabilityRef } from '../shared/codex-durability.js' + +type TerminalSessionRefSource = Pick & { + codexDurability?: CodexDurabilityRef +} + +export function buildTerminalSessionRef(record: TerminalSessionRefSource): SessionLocator | undefined { + if (!modeSupportsResume(record.mode as TerminalMode)) return undefined + if (!record.resumeSessionId) return undefined + if ( + record.mode === 'codex' + && ( + record.codexDurability?.state !== 'durable' + || record.codexDurability.durableThreadId !== record.resumeSessionId + ) + ) { + return undefined + } + + return { + provider: record.mode as CodingCliProviderName, + sessionId: record.resumeSessionId, + } +} +``` + +- [ ] **Step 5: Use the helper in registry inventory** + +In `server/terminal-registry.ts`, import: + +```ts +import { buildTerminalSessionRef } from './terminal-session-ref.js' +``` + +Then change the `list()` record mapping from the inline `sessionRef: modeSupportsResume(...) ? ... : undefined` expression to: + +```ts +sessionRef: buildTerminalSessionRef(t), +``` + +This should be behavior-preserving for inventory: OpenCode/Claude records with `resumeSessionId` expose `sessionRef`; Codex still exposes it only after durable proof. + +- [ ] **Step 6: Include sessionRef in terminal.created** + +In `server/ws-handler.ts`, import: + +```ts +import { buildTerminalSessionRef } from './terminal-session-ref.js' +import type { SessionLocator } from '../shared/ws-protocol.js' +``` + +Update `sendCreateResult` to accept and send `sessionRef`: + +```ts +const sendCreateResult = async (opts: { + ws: LiveWebSocket + requestId: string + terminalId: string + createdAt: number + sessionRef?: SessionLocator + clearCodexDurability?: boolean + restoreError?: RestoreError +}): Promise => { + if (opts.ws.readyState !== WebSocket.OPEN) { + return false + } + + this.send(opts.ws, { + type: 'terminal.created', + requestId: opts.requestId, + terminalId: opts.terminalId, + createdAt: opts.createdAt, + ...(opts.sessionRef ? { sessionRef: opts.sessionRef } : {}), + ...(opts.clearCodexDurability ? { clearCodexDurability: true } : {}), + ...(opts.restoreError ? { restoreError: opts.restoreError } : {}), + }) + return true +} +``` + +Change `attachReusedTerminal` to take a `TerminalRecord`: + +```ts +const attachReusedTerminal = async (reusedRecord: TerminalRecord): Promise => { + const sessionRef = buildTerminalSessionRef(reusedRecord) + const sent = await sendCreateResult({ + ws, + requestId: m.requestId, + terminalId: reusedRecord.terminalId, + createdAt: reusedRecord.createdAt, + sessionRef, + }) + if (!sent) return false + state.createdByRequestId.set(m.requestId, reusedRecord.terminalId) + this.rememberCreatedRequestId(m.requestId, reusedRecord.terminalId) + terminalId = reusedRecord.terminalId + reused = true + recordSessionLifecycleEvent({ + kind: 'terminal_created', + requestId: m.requestId, + connectionId: ws.connectionId || 'unknown', + terminalId: reusedRecord.terminalId, + ...(m.tabId ? { tabId: m.tabId } : {}), + ...(m.paneId ? { paneId: m.paneId } : {}), + ...(m.cwd ? { cwd: m.cwd } : {}), + mode: m.mode as TerminalMode, + reused: true, + hasSessionRef: !!sessionRef, + }) + this.broadcastTerminalsChanged() + return true +} +``` + +Then replace calls like: + +```ts +await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) +``` + +with: + +```ts +await attachReusedTerminal(existing) +``` + +For newly created terminals, send: + +```ts +const sent = await sendCreateResult({ + ws, + requestId: m.requestId, + terminalId: record.terminalId, + createdAt: record.createdAt, + sessionRef: buildTerminalSessionRef(record), + clearCodexDurability: clearCodexDurabilityOnCreate, + restoreError: restoreErrorOnCreate, +}) +``` + +- [ ] **Step 7: Include sessionRef in terminal.attach.ready** + +In `server/terminal-stream/broker.ts`, import: + +```ts +import { buildTerminalSessionRef } from '../terminal-session-ref.js' +``` + +Before sending `terminal.attach.ready`, compute: + +```ts +const sessionRef = buildTerminalSessionRef(record) +``` + +Then send: + +```ts +this.safeSend(ws, { + type: 'terminal.attach.ready', + terminalId, + headSeq, + replayFromSeq, + replayToSeq, + ...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}), + ...(sessionRef ? { sessionRef } : {}), +}) +``` + +- [ ] **Step 8: Run the focused server tests** + +Run: + +```bash +npm run test:vitest -- test/server/ws-protocol.test.ts test/unit/server/terminal-registry.test.ts --run -t "replays OpenCode sessionRef|list\\(\\) returns resumeSessionId|terminal.attach accepts paired viewport payload" +``` + +Expected: PASS. + +- [ ] **Step 9: Commit server contract work** + +Run: + +```bash +git add shared/ws-protocol.ts server/terminal-session-ref.ts server/terminal-registry.ts server/ws-handler.ts server/terminal-stream/broker.ts test/server/ws-protocol.test.ts +git commit -m "fix: replay terminal session refs over websocket" +``` + +## Task 2: Central Client SessionRef Reconciliation + +**Files:** +- Modify: `src/store/panesSlice.ts` +- Create: `src/lib/terminal-session-association.ts` +- Modify: `src/App.tsx` +- Modify: `src/components/TerminalView.tsx` +- Test: `test/unit/client/components/App.ws-bootstrap.test.tsx` +- Test: `test/unit/client/components/TerminalView.resumeSession.test.tsx` + +- [ ] **Step 1: Write failing App inventory reconciliation test** + +Add this test to `test/unit/client/components/App.ws-bootstrap.test.tsx` near the existing `terminal.inventory` tests: + +```tsx +it('recovers an OpenCode sessionRef from inventory before clearing a stale live handle', async () => { + const store = createStore({ + tabs: [{ + id: 'tab-opencode-refresh', + mode: 'opencode', + status: 'running', + resumeSessionId: 'legacy-title-like-id', + }], + panes: { + layouts: { + 'tab-opencode-refresh': { + type: 'leaf', + id: 'pane-opencode-refresh', + content: { + kind: 'terminal', + createRequestId: 'req-opencode-old', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-old', + resumeSessionId: 'legacy-title-like-id', + serverInstanceId: 'srv-old', + }, + }, + }, + activePane: { 'tab-opencode-refresh': 'pane-opencode-refresh' }, + }, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + const sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_inventory_refresh_restore', + } + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [{ + terminalId: 'term-opencode-old', + title: 'OpenCode', + mode: 'opencode', + createdAt: 1_000, + lastActivityAt: 1_700, + status: 'exited', + sessionRef, + }], + terminalMeta: [], + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-refresh'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + + expect(content.terminalId).toBeUndefined() + expect(content.status).toBe('creating') + expect(content.createRequestId).not.toBe('req-opencode-old') + expect(content.sessionRef).toEqual(sessionRef) + expect(content.resumeSessionId).toBeUndefined() + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.resumeSessionId).toBeUndefined() + expect(store.getState().panes.restoreFallbackAttemptsByPane?.['tab-opencode-refresh']?.['pane-opencode-refresh']).toBeUndefined() + expect(terminalRestoreMocks.addTerminalRestoreRequestId).toHaveBeenCalledWith(content.createRequestId) + expect(terminalRestoreMocks.addTerminalFreshRecoveryRequestId).not.toHaveBeenCalledWith( + content.createRequestId, + 'fresh_after_restore_unavailable', + ) + }) +}) +``` + +- [ ] **Step 2: Write failing App live-association test** + +Add this second test to the same file: + +```tsx +it.each(['terminal.session.associated', 'terminal.attach.ready'] as const)( + 'persists OpenCode sessionRef from %s without TerminalView mounted', + async (type) => { + const store = createStore({ + tabs: [{ id: 'tab-opencode-associated', mode: 'opencode', status: 'running' }], + panes: { + layouts: { + 'tab-opencode-associated': { + type: 'leaf', + id: 'pane-opencode-associated', + content: { + kind: 'terminal', + createRequestId: 'req-opencode-associated', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-associated', + }, + }, + }, + activePane: { 'tab-opencode-associated': 'pane-opencode-associated' }, + }, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + const sessionRef = { + provider: 'opencode', + sessionId: `ses_root_${type.replaceAll('.', '_')}`, + } + + act(() => { + messageHandler?.(type === 'terminal.session.associated' + ? { + type, + terminalId: 'term-opencode-associated', + sessionRef, + } + : { + type, + terminalId: 'term-opencode-associated', + headSeq: 0, + replayFromSeq: 1, + replayToSeq: 0, + sessionRef, + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-associated'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + expect(content.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-associated')?.sessionRef).toEqual(sessionRef) + }) + }, +) +``` + +- [ ] **Step 3: Run App tests and verify they fail** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx --run -t "OpenCode sessionRef" +``` + +Expected: FAIL because App does not reconcile session refs centrally before `clearDeadTerminals`. + +- [ ] **Step 4: Add the panes reducer** + +In `src/store/panesSlice.ts`, add this helper near other private reducer helpers: + +```ts +function sessionRefsEqual(left?: { provider?: string; sessionId?: string }, right?: { provider?: string; sessionId?: string }): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} +``` + +Add this reducer before `clearDeadTerminals`: + +```ts + reconcileTerminalSessionRefByTerminalId: ( + state, + action: PayloadAction<{ terminalId: string; sessionRef: unknown }> + ) => { + const terminalId = action.payload.terminalId + const sessionRef = sanitizeSessionRef(action.payload.sessionRef) + if (!terminalId || !sessionRef) return + + function reconcileNode(node: PaneNode, tabId: string): void { + if (node.type === 'leaf') { + const content = node.content + if ( + content.kind !== 'terminal' + || content.terminalId !== terminalId + ) { + return + } + + if (!sessionRefsEqual(content.sessionRef, sessionRef)) { + content.sessionRef = sessionRef + } + content.resumeSessionId = undefined + if ( + sessionRef.provider === 'codex' + && !( + content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ) + ) { + content.codexDurability = undefined + } + clearRestoreFallbackAttemptForPane(state, tabId, node.id) + return + } + reconcileNode(node.children[0], tabId) + reconcileNode(node.children[1], tabId) + } + + for (const [tabId, layout] of Object.entries(state.layouts)) { + reconcileNode(layout, tabId) + } + }, +``` + +Export it from the slice actions: + +```ts +export const { + // existing actions... + reconcileTerminalSessionRefByTerminalId, + clearDeadTerminals, +} = panesSlice.actions +``` + +- [ ] **Step 5: Create the central client helper** + +Create `src/lib/terminal-session-association.ts`: + +```ts +import { updateTab } from '@/store/tabsSlice' +import { + reconcileTerminalSessionRefByTerminalId, +} from '@/store/panesSlice' +import { + buildTerminalDurableSessionRefUpdate, + flushPersistedLayoutNow, +} from '@/store/persistControl' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import type { RootState } from '@/store/store' +import { sanitizeSessionRef, type SessionRef } from '@shared/session-contract' + +type Dispatch = (action: unknown) => unknown + +function collectMatchingTerminalPanes( + node: PaneNode | undefined, + terminalId: string, + out: Array<{ paneId: string; content: TerminalPaneContent }>, +): void { + if (!node) return + if (node.type === 'leaf') { + if (node.content.kind === 'terminal' && node.content.terminalId === terminalId) { + out.push({ paneId: node.id, content: node.content }) + } + return + } + collectMatchingTerminalPanes(node.children[0], terminalId, out) + collectMatchingTerminalPanes(node.children[1], terminalId, out) +} + +function isSinglePaneTerminalMatch(layout: PaneNode | undefined, terminalId: string): layout is Extract { + return Boolean( + layout + && layout.type === 'leaf' + && layout.content.kind === 'terminal' + && layout.content.terminalId === terminalId, + ) +} + +export function reconcileTerminalSessionAssociation({ + dispatch, + getState, + terminalId, + sessionRef: rawSessionRef, +}: { + dispatch: Dispatch + getState: () => RootState + terminalId?: string + sessionRef?: unknown +}): boolean { + if (!terminalId) return false + const sessionRef = sanitizeSessionRef(rawSessionRef) + if (!sessionRef) return false + + const state = getState() + const matchedTabs: Array<{ tabId: string; content: TerminalPaneContent }> = [] + for (const [tabId, layout] of Object.entries(state.panes.layouts)) { + const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] + collectMatchingTerminalPanes(layout, terminalId, matches) + if (matches.length > 0 && isSinglePaneTerminalMatch(layout, terminalId)) { + matchedTabs.push({ tabId, content: matches[0].content }) + } + } + + const hasAnyPaneMatch = matchedTabs.length > 0 || Object.values(state.panes.layouts).some((layout) => { + const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] + collectMatchingTerminalPanes(layout, terminalId, matches) + return matches.length > 0 + }) + if (!hasAnyPaneMatch) return false + + dispatch(reconcileTerminalSessionRefByTerminalId({ terminalId, sessionRef })) + + for (const { tabId, content } of matchedTabs) { + const tab = state.tabs.tabs.find((candidate) => candidate.id === tabId) + if (!tab) continue + const durableIdentityUpdate = buildTerminalDurableSessionRefUpdate({ + provider: sessionRef.provider as SessionRef['provider'], + sessionId: sessionRef.sessionId, + paneSessionRef: content.sessionRef, + tabSessionRef: tab.sessionRef, + paneResumeSessionId: content.resumeSessionId, + tabResumeSessionId: tab.resumeSessionId, + }) + const nextTabCodexDurability = sessionRef.provider === 'codex' + && tab.codexDurability?.state === 'durable' + && ( + tab.codexDurability.durableThreadId === sessionRef.sessionId + || tab.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ? tab.codexDurability + : undefined + const tabUpdates = { + ...(durableIdentityUpdate?.tabUpdates ?? {}), + ...(sessionRef.provider === 'codex' && tab.codexDurability !== nextTabCodexDurability + ? { codexDurability: nextTabCodexDurability } + : {}), + } + if (Object.keys(tabUpdates).length > 0) { + dispatch(updateTab({ + id: tab.id, + updates: tabUpdates, + })) + } + } + + dispatch(flushPersistedLayoutNow()) + return true +} +``` + +The refactor step below will simplify the duplicate `hasAnyPaneMatch` walk after the first green test pass. + +- [ ] **Step 6: Use the helper in App WebSocket handling** + +In `src/App.tsx`, import: + +```ts +import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' +``` + +In the WebSocket message handler, add this before `terminal.inventory` handling: + +```ts + if ( + (msg.type === 'terminal.session.associated' + || msg.type === 'terminal.created' + || msg.type === 'terminal.attach.ready') + && typeof (msg as any).terminalId === 'string' + && (msg as any).sessionRef + ) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: (msg as any).terminalId, + sessionRef: (msg as any).sessionRef, + }) + } +``` + +Inside `terminal.inventory`, reconcile before computing `liveIds` and before `dispatch(clearDeadTerminals(...))`: + +```ts + for (const terminal of terminals) { + if (terminal?.terminalId && terminal?.sessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: terminal.terminalId, + sessionRef: terminal.sessionRef, + }) + } + } +``` + +- [ ] **Step 7: Route TerminalView association through the central helper** + +In `src/components/TerminalView.tsx`, import `useAppStore` and the helper: + +```ts +import { useAppDispatch, useAppSelector, useAppStore } from '@/store/hooks' +import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' +``` + +Inside the component: + +```ts +const appStore = useAppStore() +``` + +When handling `terminal.attach.ready`, after validating it is current and before sequence state updates: + +```ts + if (msg.sessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: tid, + sessionRef: msg.sessionRef, + }) + } +``` + +When handling `terminal.created`, include `sessionRef` in the immediate content update so App-ordering does not matter: + +```ts + updateContent({ + terminalId: newId, + serverInstanceId: serverInstanceIdRef.current, + status: 'running', + ...(msg.sessionRef ? { sessionRef: msg.sessionRef, resumeSessionId: undefined } : {}), + ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), + ...(msg.restoreError ? { restoreError: msg.restoreError } : {}), + }) +``` + +Then call the helper after `terminalIdRef.current = newId`: + +```ts + if (msg.sessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: newId, + sessionRef: msg.sessionRef, + }) + } +``` + +Replace the current `terminal.session.associated` block with: + +```ts + if (msg.type === 'terminal.session.associated' && msg.terminalId === tid) { + const reconciled = reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: tid, + sessionRef: msg.sessionRef, + }) + if (debugRef.current && reconciled) { + log.debug('[TRACE resumeSessionId] terminal.session.associated reconciled', { + paneId: paneIdRef.current, + terminalId: tid, + sessionRef: msg.sessionRef, + }) + } + } +``` + +Remove the now-unused `buildTerminalDurableSessionRefUpdate` import from `TerminalView.tsx`. + +- [ ] **Step 8: Run focused client tests** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|persists canonical durable sessionRef" +``` + +Expected: PASS. + +- [ ] **Step 9: Commit client reconciliation work** + +Run: + +```bash +git add src/store/panesSlice.ts src/lib/terminal-session-association.ts src/App.tsx src/components/TerminalView.tsx test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx +git commit -m "fix: reconcile terminal session refs centrally" +``` + +## Task 3: End-To-End Restore Regression + +**Files:** +- Test: `test/e2e/terminal-restart-recovery.test.tsx` + +- [ ] **Step 1: Write the failing e2e regression** + +Add this test to `test/e2e/terminal-restart-recovery.test.tsx`: + +```tsx +it('restores an OpenCode pane after inventory recovers a missing sessionRef before stale-handle cleanup', async () => { + const layout: PaneNode = { + type: 'leaf', + id: 'pane-opencode', + content: { + kind: 'terminal', + createRequestId: 'req-opencode-old', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-old', + serverInstanceId: 'srv-old', + } satisfies TerminalPaneContent, + } + const store = createStore(layout) + + render( + + + , + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-opencode-old')).toBe(true) + }) + + wsHarness.send.mockClear() + store.dispatch(reconcileTerminalSessionRefByTerminalId({ + terminalId: 'term-opencode-old', + sessionRef: { + provider: 'opencode', + sessionId: 'ses_root_recovered_before_dead_clear', + }, + })) + store.dispatch(clearDeadTerminals({ liveTerminalIds: [] })) + registerRecoveryRequestsFromState(store) + + await waitFor(() => { + const create = sentMessages().find((msg) => msg?.type === 'terminal.create') + expect(create).toMatchObject({ + type: 'terminal.create', + mode: 'opencode', + restore: true, + sessionRef: { + provider: 'opencode', + sessionId: 'ses_root_recovered_before_dead_clear', + }, + }) + expect(create).not.toHaveProperty('recoveryIntent') + }) +}) +``` + +Also import the reducer: + +```ts +import panesReducer, { clearDeadTerminals, reconcileTerminalSessionRefByTerminalId } from '@/store/panesSlice' +``` + +- [ ] **Step 2: Run the e2e regression** + +Run: + +```bash +npm run test:vitest -- test/e2e/terminal-restart-recovery.test.tsx --run -t "inventory recovers a missing sessionRef" +``` + +Expected before Task 2 implementation: FAIL. Expected after Task 2 implementation: PASS. + +- [ ] **Step 3: Commit e2e regression** + +Run: + +```bash +git add test/e2e/terminal-restart-recovery.test.tsx +git commit -m "test: cover opencode restore after inventory identity recovery" +``` + +## Task 4: Focused Verification And Refactor + +**Files:** +- Modify only files touched by Tasks 1-3 if cleanup is needed. + +- [ ] **Step 1: Run focused server verification** + +Run: + +```bash +npm run test:vitest -- test/server/ws-protocol.test.ts test/integration/server/opencode-session-flow.test.ts test/unit/server/coding-cli/opencode-session-controller.test.ts test/unit/server/coding-cli/opencode-activity-wiring.test.ts --run -t "opencode|OpenCode|replays OpenCode sessionRef|terminal.attach accepts paired viewport payload" +``` + +Expected: PASS. + +- [ ] **Step 2: Run focused client verification** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx test/e2e/terminal-restart-recovery.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|persists canonical durable sessionRef|inventory recovers a missing sessionRef|registers regenerated restart request ids" +``` + +Expected: PASS. + +- [ ] **Step 3: Run typecheck** + +Run: + +```bash +npm run check +``` + +Expected: PASS except for the known user-accepted OpenCode real-provider DB-row flake only if it recurs in broad verification. + +- [ ] **Step 4: Refactor duplicated traversal if needed** + +If `src/lib/terminal-session-association.ts` has duplicate tree walks after implementation, replace them with one pass: + +```ts +const matchedByTab: Array<{ tabId: string; singlePane: boolean; content: TerminalPaneContent }> = [] +for (const [tabId, layout] of Object.entries(state.panes.layouts)) { + const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] + collectMatchingTerminalPanes(layout, terminalId, matches) + if (matches.length === 0) continue + matchedAnyPane = true + if (isSinglePaneTerminalMatch(layout, terminalId)) { + matchedByTab.push({ tabId, singlePane: true, content: matches[0].content }) + } +} +``` + +Run the focused client tests again after any refactor. + +- [ ] **Step 5: Commit verification cleanup** + +Run: + +```bash +git status --short +git add src test server shared +git commit -m "refactor: simplify terminal session association reconciliation" +``` + +If there are no cleanup changes, skip this commit. + +## Task 5: Final Verification And Delivery + +**Files:** +- No planned file edits. + +- [ ] **Step 1: Run broad coordinated verification** + +Run: + +```bash +FRESHELL_TEST_SUMMARY='opencode browser refresh restore final verification' npm test +``` + +Expected: PASS. If the same real-provider OpenCode DB-row timeout from the accepted baseline appears, rerun that failing test once and record both outputs. Do not claim broad green unless the coordinated suite exits 0. + +- [ ] **Step 2: Run build/type verification if broad test passes** + +Run: + +```bash +FRESHELL_TEST_SUMMARY='opencode browser refresh restore final check' npm run check +``` + +Expected: PASS. + +- [ ] **Step 3: Confirm no unwanted changes** + +Run: + +```bash +git status --short +git log --oneline --decorate -5 +``` + +Expected: only intentional committed changes on `opencode-browser-refresh-restore`. + +- [ ] **Step 4: Commit any final fixes** + +If verification required fixes: + +```bash +git add shared server src test +git commit -m "fix: stabilize opencode refresh restore reconciliation" +``` + +If no files changed, skip this step. + +## Self-Review + +**Spec coverage:** The plan implements every claim in `/tmp/freshell-opencode-restore-theory.md`: server replay through inventory/create/attach, central client reconciliation, inventory-before-clear ordering, no database guessing, fresh-fallback clearing, tab fallback updates for single-pane tabs, and restore-create coverage after stale handle cleanup. + +**Placeholder scan:** There are no `TBD`, `TODO`, or "similar to" placeholders. Task 3 has full test code and is part of the required verification path. + +**Type consistency:** The same `SessionLocator`/`SessionRef` shape is used throughout: `{ provider, sessionId }`. Server code uses `.js` extensions for new ESM imports. Client store code uses existing `sanitizeSessionRef`, `buildTerminalDurableSessionRefUpdate`, `updateTab`, `flushPersistedLayoutNow`, and `clearRestoreFallbackAttemptForPane` patterns. From 9baea3cc479f4fdb1010e4572a949af53fc6a0eb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 01:52:08 -0700 Subject: [PATCH 02/11] plan: address opencode restore review --- ...-06-01-opencode-browser-refresh-restore.md | 181 ++++++++++++++++-- 1 file changed, 162 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md index fb89a79b..b4d16874 100644 --- a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md +++ b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md @@ -293,6 +293,8 @@ with: await attachReusedTerminal(existing) ``` +Update all six existing call sites in `server/ws-handler.ts` the same way, including the live Codex proof branches that currently pass `decision.sessionId` or `live.resumeSessionId`. The new helper always derives the replayed identity from the current `TerminalRecord`; do not pass a session id separately. + For newly created terminals, send: ```ts @@ -422,13 +424,36 @@ it('recovers an OpenCode sessionRef from inventory before clearing a stale live mode: 'opencode', createdAt: 1_000, lastActivityAt: 1_700, - status: 'exited', + status: 'running', sessionRef, }], terminalMeta: [], }) }) + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-refresh'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + + expect(content.terminalId).toBe('term-opencode-old') + expect(content.status).toBe('running') + expect(content.createRequestId).toBe('req-opencode-old') + expect(content.sessionRef).toEqual(sessionRef) + expect(content.resumeSessionId).toBeUndefined() + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.resumeSessionId).toBeUndefined() + }) + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [], + terminalMeta: [], + }) + }) + await waitFor(() => { const layout = store.getState().panes.layouts['tab-opencode-refresh'] if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') @@ -440,8 +465,6 @@ it('recovers an OpenCode sessionRef from inventory before clearing a stale live expect(content.createRequestId).not.toBe('req-opencode-old') expect(content.sessionRef).toEqual(sessionRef) expect(content.resumeSessionId).toBeUndefined() - expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.sessionRef).toEqual(sessionRef) - expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.resumeSessionId).toBeUndefined() expect(store.getState().panes.restoreFallbackAttemptsByPane?.['tab-opencode-refresh']?.['pane-opencode-refresh']).toBeUndefined() expect(terminalRestoreMocks.addTerminalRestoreRequestId).toHaveBeenCalledWith(content.createRequestId) expect(terminalRestoreMocks.addTerminalFreshRecoveryRequestId).not.toHaveBeenCalledWith( @@ -649,6 +672,28 @@ function isSinglePaneTerminalMatch(layout: PaneNode | undefined, terminalId: str ) } +function sessionRefsEqual(left?: SessionRef, right?: SessionRef): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} + +function terminalPaneNeedsDurableIdentityUpdate(content: TerminalPaneContent, sessionRef: SessionRef): boolean { + if (!sessionRefsEqual(content.sessionRef, sessionRef)) return true + if (typeof content.resumeSessionId === 'string') return true + if ( + sessionRef.provider === 'codex' + && !( + content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ) + ) { + return content.codexDurability !== undefined + } + return false +} + export function reconcileTerminalSessionAssociation({ dispatch, getState, @@ -665,25 +710,27 @@ export function reconcileTerminalSessionAssociation({ if (!sessionRef) return false const state = getState() - const matchedTabs: Array<{ tabId: string; content: TerminalPaneContent }> = [] + let matchedAnyPane = false + let shouldFlush = false + const matchedSinglePaneTabs: Array<{ tabId: string; content: TerminalPaneContent }> = [] for (const [tabId, layout] of Object.entries(state.panes.layouts)) { const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] collectMatchingTerminalPanes(layout, terminalId, matches) - if (matches.length > 0 && isSinglePaneTerminalMatch(layout, terminalId)) { - matchedTabs.push({ tabId, content: matches[0].content }) + if (matches.length === 0) continue + matchedAnyPane = true + if (matches.some(({ content }) => terminalPaneNeedsDurableIdentityUpdate(content, sessionRef))) { + shouldFlush = true + } + if (isSinglePaneTerminalMatch(layout, terminalId)) { + matchedSinglePaneTabs.push({ tabId, content: matches[0].content }) } } - const hasAnyPaneMatch = matchedTabs.length > 0 || Object.values(state.panes.layouts).some((layout) => { - const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] - collectMatchingTerminalPanes(layout, terminalId, matches) - return matches.length > 0 - }) - if (!hasAnyPaneMatch) return false + if (!matchedAnyPane) return false dispatch(reconcileTerminalSessionRefByTerminalId({ terminalId, sessionRef })) - for (const { tabId, content } of matchedTabs) { + for (const { tabId, content } of matchedSinglePaneTabs) { const tab = state.tabs.tabs.find((candidate) => candidate.id === tabId) if (!tab) continue const durableIdentityUpdate = buildTerminalDurableSessionRefUpdate({ @@ -709,6 +756,7 @@ export function reconcileTerminalSessionAssociation({ : {}), } if (Object.keys(tabUpdates).length > 0) { + shouldFlush = true dispatch(updateTab({ id: tab.id, updates: tabUpdates, @@ -716,12 +764,14 @@ export function reconcileTerminalSessionAssociation({ } } - dispatch(flushPersistedLayoutNow()) + if (shouldFlush) { + dispatch(flushPersistedLayoutNow()) + } return true } ``` -The refactor step below will simplify the duplicate `hasAnyPaneMatch` walk after the first green test pass. +The helper computes `shouldFlush` before dispatching the panes reducer so repeated `terminal.attach.ready` messages that replay an already-persisted `sessionRef` do not force redundant synchronous layout writes. - [ ] **Step 6: Use the helper in App WebSocket handling** @@ -841,17 +891,110 @@ Replace the current `terminal.session.associated` block with: Remove the now-unused `buildTerminalDurableSessionRefUpdate` import from `TerminalView.tsx`. -- [ ] **Step 8: Run focused client tests** +- [ ] **Step 8: Add the TerminalView terminal.created assertion** + +In `test/unit/client/components/TerminalView.resumeSession.test.tsx`, keep the existing "keeps terminal.created live-only until an explicit terminal.session.associated arrives" test unchanged for messages with no `sessionRef`. Add this assertion in the same describe block: + +```tsx +it('persists canonical sessionRef from terminal.created when the server replays it', 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 sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_created_replay', + } + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-created-replay', + status: 'creating', + mode: 'opencode', + shell: 'system', + initialCwd: '/tmp', + } + 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-created-replay', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' }, + connection: { status: 'connected', error: null, serverInstanceId: 'srv-local' }, + }, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + requestId: 'req-created-replay', + })) + }) + + messageHandler?.({ + type: 'terminal.created', + requestId: 'req-created-replay', + terminalId: 'term-created-replay', + sessionRef, + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts[tabId] + if (layout?.type !== 'leaf') throw new Error('unexpected layout') + if (layout.content.kind !== 'terminal') throw new Error('unexpected content') + expect(layout.content.terminalId).toBe('term-created-replay') + expect(layout.content.sessionRef).toEqual(sessionRef) + expect(layout.content.resumeSessionId).toBeUndefined() + + const tab = store.getState().tabs.tabs.find((entry) => entry.id === tabId) + expect(tab?.sessionRef).toEqual(sessionRef) + expect(tab?.resumeSessionId).toBeUndefined() + }) +}) +``` + +- [ ] **Step 9: Run focused client tests** Run: ```bash -npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|persists canonical durable sessionRef" +npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|terminal.created when the server replays it|persists canonical durable sessionRef|shows feedback when Codex input is blocked" ``` Expected: PASS. -- [ ] **Step 9: Commit client reconciliation work** +- [ ] **Step 10: Commit client reconciliation work** Run: @@ -968,7 +1111,7 @@ Expected: PASS. Run: ```bash -npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx test/e2e/terminal-restart-recovery.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|persists canonical durable sessionRef|inventory recovers a missing sessionRef|registers regenerated restart request ids" +npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx test/e2e/terminal-restart-recovery.test.tsx test/e2e/codex-refresh-rehydrate-flow.test.tsx --run -t "OpenCode sessionRef|terminal.session.associated|terminal.created live-only|terminal.created when the server replays it|persists canonical durable sessionRef|inventory recovers a missing sessionRef|registers regenerated restart request ids|restores the same Codex session after a refresh" ``` Expected: PASS. From fbe1e8ed5396b7a5cda938653c008f20676df0b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:10:30 -0700 Subject: [PATCH 03/11] plan: tighten opencode restore execution details --- ...-06-01-opencode-browser-refresh-restore.md | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md index b4d16874..737bfc85 100644 --- a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md +++ b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md @@ -26,10 +26,8 @@ Existing durable identity contracts to preserve: - Modify `shared/ws-protocol.ts` - Add optional `sessionRef?: SessionLocator` to `TerminalCreatedMessage` and `TerminalAttachReadyMessage`. -- Create `server/terminal-session-ref.ts` - - Central server helper for exposing a terminal record's already-known canonical durable `sessionRef`. - Modify `server/terminal-registry.ts` - - Reuse the helper in `list()` so inventory keeps the existing behavior with less duplicated logic. + - Add a helper for exposing a terminal record's already-known canonical durable `sessionRef`, and reuse it in `list()` so inventory keeps the existing behavior with less duplicated logic. - Modify `server/ws-handler.ts` - Include `sessionRef` on `terminal.created` when the terminal record already has canonical durable identity. - Include the same identity for reused existing terminals. @@ -58,7 +56,6 @@ Existing durable identity contracts to preserve: **Files:** - Modify: `shared/ws-protocol.ts` -- Create: `server/terminal-session-ref.ts` - Modify: `server/terminal-registry.ts` - Modify: `server/ws-handler.ts` - Modify: `server/terminal-stream/broker.ts` @@ -159,16 +156,17 @@ export type TerminalAttachReadyMessage = { } ``` -- [ ] **Step 4: Create the server sessionRef helper** +- [ ] **Step 4: Add the server sessionRef helper** -Create `server/terminal-session-ref.ts`: +In `server/terminal-registry.ts`, add `SessionLocator` to the existing shared protocol type imports if needed: ```ts import type { SessionLocator } from '../shared/ws-protocol.js' -import { modeSupportsResume, type TerminalMode, type TerminalRecord } from './terminal-registry.js' -import type { CodingCliProviderName } from './coding-cli/types.js' -import type { CodexDurabilityRef } from '../shared/codex-durability.js' +``` + +Then add this helper after `modeSupportsResume`: +```ts type TerminalSessionRefSource = Pick & { codexDurability?: CodexDurabilityRef } @@ -195,13 +193,7 @@ export function buildTerminalSessionRef(record: TerminalSessionRefSource): Sessi - [ ] **Step 5: Use the helper in registry inventory** -In `server/terminal-registry.ts`, import: - -```ts -import { buildTerminalSessionRef } from './terminal-session-ref.js' -``` - -Then change the `list()` record mapping from the inline `sessionRef: modeSupportsResume(...) ? ... : undefined` expression to: +In `server/terminal-registry.ts`, change the `list()` record mapping from the inline `sessionRef: modeSupportsResume(...) ? ... : undefined` expression to: ```ts sessionRef: buildTerminalSessionRef(t), @@ -214,7 +206,7 @@ This should be behavior-preserving for inventory: OpenCode/Claude records with ` In `server/ws-handler.ts`, import: ```ts -import { buildTerminalSessionRef } from './terminal-session-ref.js' +import { buildTerminalSessionRef, modeSupportsResume, terminalIdFromCreateError } from './terminal-registry.js' import type { SessionLocator } from '../shared/ws-protocol.js' ``` @@ -293,7 +285,7 @@ with: await attachReusedTerminal(existing) ``` -Update all six existing call sites in `server/ws-handler.ts` the same way, including the live Codex proof branches that currently pass `decision.sessionId` or `live.resumeSessionId`. The new helper always derives the replayed identity from the current `TerminalRecord`; do not pass a session id separately. +Update all seven existing call sites in `server/ws-handler.ts` the same way, including the live Codex proof branches that currently pass `decision.sessionId` or `live.resumeSessionId`. The new helper always derives the replayed identity from the current `TerminalRecord`; do not pass a session id separately. For newly created terminals, send: @@ -314,7 +306,7 @@ const sent = await sendCreateResult({ In `server/terminal-stream/broker.ts`, import: ```ts -import { buildTerminalSessionRef } from '../terminal-session-ref.js' +import { buildTerminalSessionRef, type TerminalRegistry } from '../terminal-registry.js' ``` Before sending `terminal.attach.ready`, compute: @@ -352,7 +344,7 @@ Expected: PASS. Run: ```bash -git add shared/ws-protocol.ts server/terminal-session-ref.ts server/terminal-registry.ts server/ws-handler.ts server/terminal-stream/broker.ts test/server/ws-protocol.test.ts +git add shared/ws-protocol.ts server/terminal-registry.ts server/ws-handler.ts server/terminal-stream/broker.ts test/server/ws-protocol.test.ts git commit -m "fix: replay terminal session refs over websocket" ``` @@ -1008,15 +1000,16 @@ git commit -m "fix: reconcile terminal session refs centrally" **Files:** - Test: `test/e2e/terminal-restart-recovery.test.tsx` -- [ ] **Step 1: Write the failing e2e regression** +- [ ] **Step 1: Write the e2e regression guard** Add this test to `test/e2e/terminal-restart-recovery.test.tsx`: ```tsx it('restores an OpenCode pane after inventory recovers a missing sessionRef before stale-handle cleanup', async () => { + const paneId = 'pane-codex' const layout: PaneNode = { type: 'leaf', - id: 'pane-opencode', + id: paneId, content: { kind: 'terminal', createRequestId: 'req-opencode-old', @@ -1031,7 +1024,7 @@ it('restores an OpenCode pane after inventory recovers a missing sessionRef befo render( - + , ) @@ -1080,7 +1073,7 @@ Run: npm run test:vitest -- test/e2e/terminal-restart-recovery.test.tsx --run -t "inventory recovers a missing sessionRef" ``` -Expected before Task 2 implementation: FAIL. Expected after Task 2 implementation: PASS. +Expected: PASS after Task 2. This is a regression guard for the restored-create emission path after the reducer has learned the recovered OpenCode identity. - [ ] **Step 3: Commit e2e regression** From 2585a0377bf5f5d1f0ec5270b5a6b1c1192c8c3f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:28:57 -0700 Subject: [PATCH 04/11] plan: apply final opencode restore review notes --- ...-06-01-opencode-browser-refresh-restore.md | 75 +++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md index 737bfc85..07312abe 100644 --- a/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md +++ b/docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md @@ -158,7 +158,7 @@ export type TerminalAttachReadyMessage = { - [ ] **Step 4: Add the server sessionRef helper** -In `server/terminal-registry.ts`, add `SessionLocator` to the existing shared protocol type imports if needed: +In `server/terminal-registry.ts`, add this type import: ```ts import type { SessionLocator } from '../shared/ws-protocol.js' @@ -201,6 +201,53 @@ sessionRef: buildTerminalSessionRef(t), This should be behavior-preserving for inventory: OpenCode/Claude records with `resumeSessionId` expose `sessionRef`; Codex still exposes it only after durable proof. +Add `buildTerminalSessionRef` to the existing `test/unit/server/terminal-registry.test.ts` import from `../../../server/terminal-registry`, then add focused unit coverage for the helper: + +```ts +describe('buildTerminalSessionRef', () => { + it('exposes non-Codex resumable provider session refs from resumeSessionId', () => { + expect(buildTerminalSessionRef({ + mode: 'opencode', + resumeSessionId: 'ses_root_unit_helper', + })).toEqual({ + provider: 'opencode', + sessionId: 'ses_root_unit_helper', + }) + }) + + it('exposes Codex session refs only after durable proof matches the resume id', () => { + expect(buildTerminalSessionRef({ + mode: 'codex', + resumeSessionId: 'thread-codex-durable', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-codex-durable', + }, + })).toEqual({ + provider: 'codex', + sessionId: 'thread-codex-durable', + }) + + expect(buildTerminalSessionRef({ + mode: 'codex', + resumeSessionId: 'thread-codex-unproved', + codexDurability: { + schemaVersion: 1, + state: 'captured_pre_turn', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-codex-unproved', + rolloutPath: '/tmp/codex-rollout.json', + capturedAt: 1, + source: 'thread_start_response', + }, + }, + })).toBeUndefined() + }) +}) +``` + - [ ] **Step 6: Include sessionRef in terminal.created** In `server/ws-handler.ts`, import: @@ -306,7 +353,8 @@ const sent = await sendCreateResult({ In `server/terminal-stream/broker.ts`, import: ```ts -import { buildTerminalSessionRef, type TerminalRegistry } from '../terminal-registry.js' +import { buildTerminalSessionRef } from '../terminal-registry.js' +import type { TerminalRegistry } from '../terminal-registry.js' ``` Before sending `terminal.attach.ready`, compute: @@ -334,7 +382,7 @@ this.safeSend(ws, { Run: ```bash -npm run test:vitest -- test/server/ws-protocol.test.ts test/unit/server/terminal-registry.test.ts --run -t "replays OpenCode sessionRef|list\\(\\) returns resumeSessionId|terminal.attach accepts paired viewport payload" +npm run test:vitest -- test/server/ws-protocol.test.ts test/unit/server/terminal-registry.test.ts --run -t "replays OpenCode sessionRef|buildTerminalSessionRef|terminal.attach accepts paired viewport payload" ``` Expected: PASS. @@ -878,6 +926,23 @@ Replace the current `terminal.session.associated` block with: sessionRef: msg.sessionRef, }) } + if (reconciled && contentRef.current) { + const current = contentRef.current + const nextCodexDurability = msg.sessionRef.provider === 'codex' + && current.codexDurability?.state === 'durable' + && ( + current.codexDurability.durableThreadId === msg.sessionRef.sessionId + || current.codexDurability.candidate?.candidateThreadId === msg.sessionRef.sessionId + ) + ? current.codexDurability + : undefined + contentRef.current = { + ...current, + sessionRef: msg.sessionRef, + resumeSessionId: undefined, + ...(msg.sessionRef.provider === 'codex' ? { codexDurability: nextCodexDurability } : {}), + } + } } ``` @@ -1119,9 +1184,9 @@ npm run check Expected: PASS except for the known user-accepted OpenCode real-provider DB-row flake only if it recurs in broad verification. -- [ ] **Step 4: Refactor duplicated traversal if needed** +- [ ] **Step 4: Confirm traversal is single-pass** -If `src/lib/terminal-session-association.ts` has duplicate tree walks after implementation, replace them with one pass: +Ensure `src/lib/terminal-session-association.ts` keeps one tree walk when collecting matching panes: ```ts const matchedByTab: Array<{ tabId: string; singlePane: boolean; content: TerminalPaneContent }> = [] From 8e1492b403566700a432d67b60eb65577c09918e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:37:33 -0700 Subject: [PATCH 05/11] fix: replay terminal session refs over websocket --- server/terminal-registry.ts | 33 ++++++++--- server/terminal-stream/broker.ts | 4 +- server/ws-handler.ts | 49 +++++++--------- shared/ws-protocol.ts | 2 + test/server/ws-protocol.test.ts | 56 ++++++++++++++++++ test/unit/server/terminal-registry.test.ts | 67 +++++++++++++++++++++- 6 files changed, 174 insertions(+), 37 deletions(-) diff --git a/server/terminal-registry.ts b/server/terminal-registry.ts index 24dd9d8f..60954f08 100644 --- a/server/terminal-registry.ts +++ b/server/terminal-registry.ts @@ -9,6 +9,7 @@ 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 type { SessionLocator } from '../shared/ws-protocol.js' import { CODEX_DURABILITY_SCHEMA_VERSION, type CodexCandidateSource, @@ -132,6 +133,29 @@ export function modeSupportsResume(mode: TerminalMode): boolean { return !!codingCliCommands.get(mode)?.resumeArgs } +type TerminalSessionRefSource = Pick & { + codexDurability?: CodexDurabilityRef +} + +export function buildTerminalSessionRef(record: TerminalSessionRefSource): SessionLocator | undefined { + if (!modeSupportsResume(record.mode as TerminalMode)) return undefined + if (!record.resumeSessionId) return undefined + if ( + record.mode === 'codex' + && ( + record.codexDurability?.state !== 'durable' + || record.codexDurability.durableThreadId !== record.resumeSessionId + ) + ) { + return undefined + } + + return { + provider: record.mode as CodingCliProviderName, + sessionId: record.resumeSessionId, + } +} + type ProviderTarget = 'unix' | 'windows' function providerNotificationArgs( @@ -3295,14 +3319,7 @@ 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, + sessionRef: buildTerminalSessionRef(t), createdAt: t.createdAt, lastActivityAt: t.lastActivityAt, status: t.status, diff --git a/server/terminal-stream/broker.ts b/server/terminal-stream/broker.ts index fbf2fbee..c69c7e62 100644 --- a/server/terminal-stream/broker.ts +++ b/server/terminal-stream/broker.ts @@ -1,6 +1,6 @@ import WebSocket from 'ws' import type { LiveWebSocket } from '../ws-handler.js' -import type { TerminalRegistry } from '../terminal-registry.js' +import { buildTerminalSessionRef, type TerminalRegistry } from '../terminal-registry.js' import { logger } from '../logger.js' import { logTerminalStreamPerfEvent, type TerminalStreamPerfEvent } from '../perf-logger.js' import type { TerminalOutputRawEvent } from './registry-events.js' @@ -187,6 +187,7 @@ export class TerminalStreamBroker { }) } + const sessionRef = buildTerminalSessionRef(record) if (!this.safeSend(ws, { type: 'terminal.attach.ready', terminalId, @@ -194,6 +195,7 @@ export class TerminalStreamBroker { replayFromSeq, replayToSeq, ...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}), + ...(sessionRef ? { sessionRef } : {}), })) { return } diff --git a/server/ws-handler.ts b/server/ws-handler.ts index 5add9476..3342e8e5 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -6,7 +6,7 @@ import { logger } from './logger.js' import { recordSessionLifecycleEvent } from './session-observability.js' import { getPerfConfig, logPerfEvent, shouldLog, startPerfTimer } from './perf-logger.js' import { getRequiredAuthToken, isLoopbackAddress, isOriginAllowed, timingSafeCompare } from './auth.js' -import { modeSupportsResume, terminalIdFromCreateError } from './terminal-registry.js' +import { buildTerminalSessionRef, modeSupportsResume, terminalIdFromCreateError } from './terminal-registry.js' import type { TerminalRecord, TerminalRegistry, TerminalMode } from './terminal-registry.js' import { configStore, type ConfigReadError, type UserConfig } from './config-store.js' import type { CodingCliSessionManager } from './coding-cli/session-manager.js' @@ -2417,9 +2417,7 @@ export class WsHandler { const sendCreateResult = async (opts: { ws: LiveWebSocket requestId: string - terminalId: string - createdAt: number - effectiveResumeSessionId?: string + record: TerminalRecord clearCodexDurability?: boolean restoreError?: RestoreError }): Promise => { @@ -2427,11 +2425,13 @@ export class WsHandler { return false } + const sessionRef = buildTerminalSessionRef(opts.record) this.send(opts.ws, { type: 'terminal.created', requestId: opts.requestId, - terminalId: opts.terminalId, - createdAt: opts.createdAt, + terminalId: opts.record.terminalId, + createdAt: opts.record.createdAt, + ...(sessionRef ? { sessionRef } : {}), ...(opts.clearCodexDurability ? { clearCodexDurability: true } : {}), ...(opts.restoreError ? { restoreError: opts.restoreError } : {}), }) @@ -2439,35 +2439,32 @@ export class WsHandler { } const attachReusedTerminal = async ( - reusedTerminalId: string, - createdAt: number, - resumeSessionId?: string, + record: TerminalRecord, ): Promise => { const sent = await sendCreateResult({ ws, requestId: m.requestId, - terminalId: reusedTerminalId, - createdAt, - effectiveResumeSessionId: resumeSessionId, + record, }) if (!sent) { return false } - state.createdByRequestId.set(m.requestId, reusedTerminalId) - this.rememberCreatedRequestId(m.requestId, reusedTerminalId) - terminalId = reusedTerminalId + state.createdByRequestId.set(m.requestId, record.terminalId) + this.rememberCreatedRequestId(m.requestId, record.terminalId) + terminalId = record.terminalId reused = true + const sessionRef = buildTerminalSessionRef(record) recordSessionLifecycleEvent({ kind: 'terminal_created', requestId: m.requestId, connectionId: ws.connectionId || 'unknown', - terminalId: reusedTerminalId, + terminalId: record.terminalId, ...(m.tabId ? { tabId: m.tabId } : {}), ...(m.paneId ? { paneId: m.paneId } : {}), ...(m.cwd ? { cwd: m.cwd } : {}), mode: m.mode as TerminalMode, reused: true, - hasSessionRef: !!resumeSessionId, + hasSessionRef: !!sessionRef, }) this.broadcastTerminalsChanged() return true @@ -2526,7 +2523,7 @@ export class WsHandler { } const existing = this.registry.get(existingId) if (existing) { - await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) + await attachReusedTerminal(existing) return } // If it no longer exists, fall through and create a new one. @@ -2602,7 +2599,7 @@ export class WsHandler { state: 'durable', durableThreadId: decision.sessionId, }) - await attachReusedTerminal(live.terminalId, live.createdAt, decision.sessionId) + await attachReusedTerminal(live) broadcastCodexSessionAssociated(live.terminalId, decision.sessionId) return } @@ -2637,7 +2634,7 @@ export class WsHandler { 'restore_proof_failed_attached_live', ) } - await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId) + await attachReusedTerminal(live) return } else if (decision.kind === 'proof_failed_fresh_create') { const { candidate, proof } = decision @@ -2661,7 +2658,7 @@ export class WsHandler { if (!codexDurabilityForDecision?.candidate) { const live = requestedLiveTerminal() if (live) { - await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId) + await attachReusedTerminal(live) return } } @@ -2686,7 +2683,7 @@ export class WsHandler { codexDurabilityStoreRecordToDeleteOnSuccessfulUse, 'restore_proof_succeeded_attached_existing', ) - await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) + await attachReusedTerminal(existing) return } } @@ -2706,7 +2703,7 @@ export class WsHandler { } const existing = this.registry.get(existingAfterConfigId) if (existing) { - await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) + await attachReusedTerminal(existing) return } state.createdByRequestId.delete(m.requestId) @@ -2750,7 +2747,7 @@ export class WsHandler { codexDurabilityStoreRecordToDeleteOnSuccessfulUse, 'restore_proof_succeeded_attached_existing', ) - await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId) + await attachReusedTerminal(existing) return } } @@ -2913,9 +2910,7 @@ export class WsHandler { const sent = await sendCreateResult({ ws, requestId: m.requestId, - terminalId: record.terminalId, - createdAt: record.createdAt, - effectiveResumeSessionId, + record, clearCodexDurability: clearCodexDurabilityOnCreate, restoreError: restoreErrorOnCreate, }) diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 95ccc8aa..f08c74eb 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -633,6 +633,7 @@ export type TerminalCreatedMessage = { requestId: string terminalId: string createdAt: number + sessionRef?: SessionLocator clearCodexDurability?: boolean restoreError?: RestoreError } @@ -644,6 +645,7 @@ export type TerminalAttachReadyMessage = { replayFromSeq: number replayToSeq: number attachRequestId?: string + sessionRef?: SessionLocator } export type TerminalDetachedMessage = { diff --git a/test/server/ws-protocol.test.ts b/test/server/ws-protocol.test.ts index 631294d5..dc0fa84b 100644 --- a/test/server/ws-protocol.test.ts +++ b/test/server/ws-protocol.test.ts @@ -22,6 +22,7 @@ vi.setConfig({ testTimeout: TEST_TIMEOUT_MS, hookTimeout: HOOK_TIMEOUT_MS }) // Mock the config-store module before importing ws-handler const mockConfigStore = vi.hoisted(() => ({ snapshot: vi.fn(), + pushRecentDirectory: vi.fn(), })) vi.mock('../../server/config-store', () => ({ @@ -346,6 +347,8 @@ describe('ws protocol', () => { // Clear registry state between tests mockConfigStore.snapshot.mockReset() mockConfigStore.snapshot.mockResolvedValue(defaultConfigSnapshot()) + mockConfigStore.pushRecentDirectory.mockReset() + mockConfigStore.pushRecentDirectory.mockResolvedValue(undefined) registry.records.clear() registry.createCalls = [] registry.inputCalls = [] @@ -1013,6 +1016,59 @@ describe('ws protocol', () => { await close() }) + it('replays OpenCode sessionRef on terminal.created and terminal.attach.ready for restored terminals', async () => { + const { ws, close } = await createAuthenticatedConnection() + const requestId = 'req-opencode-restored-session-ref' + const sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_browser_refresh_restore', + } + + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId, + mode: 'opencode', + restore: true, + sessionRef, + cwd: '/repo/project', + })) + + const created = await waitForMessage( + ws, + (msg) => msg.type === 'terminal.created' && msg.requestId === requestId, + 5000, + ) + + expect(created).toMatchObject({ + type: 'terminal.created', + requestId, + sessionRef, + }) + + ws.send(JSON.stringify({ + type: 'terminal.attach', + terminalId: created.terminalId, + intent: 'viewport_hydrate', + cols: 120, + rows: 40, + sinceSeq: 0, + })) + + const ready = await waitForMessage( + ws, + (msg) => msg.type === 'terminal.attach.ready' && msg.terminalId === created.terminalId, + 5000, + ) + + expect(ready).toMatchObject({ + type: 'terminal.attach.ready', + terminalId: created.terminalId, + sessionRef, + }) + + await close() + }) + // Helper to collect messages until a condition is met function collectUntil(ws: WebSocket, predicate: (msg: any) => boolean, timeoutMs = 1000): Promise { return new Promise((resolve, reject) => { diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index e2f595df..6bc5f74a 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { isLinuxPath, getSystemShell, escapeCmdExe, buildSpawnSpec, TerminalRegistry, isWsl, isWindowsLike, modeSupportsResume } from '../../../server/terminal-registry' +import { isLinuxPath, getSystemShell, escapeCmdExe, buildSpawnSpec, TerminalRegistry, isWsl, isWindowsLike, modeSupportsResume, buildTerminalSessionRef } from '../../../server/terminal-registry' import { isValidClaudeSessionId } from '../../../server/claude-session-id' +import { CODEX_DURABILITY_SCHEMA_VERSION } from '../../../shared/codex-durability' import * as fs from 'fs' import os from 'os' import { @@ -75,6 +76,70 @@ const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' const OTHER_CLAUDE_SESSION_ID = '6f1c2b3a-4d5e-4f70-8a9b-0c1d2e3f4a5b' const TEST_OPENCODE_SERVER = { hostname: '127.0.0.1' as const, port: 4173 } +describe('buildTerminalSessionRef', () => { + it('exposes non-Codex resumable provider session refs from resumeSessionId', () => { + expect(buildTerminalSessionRef({ + mode: 'opencode', + resumeSessionId: 'ses_root_unit_helper', + })).toEqual({ + provider: 'opencode', + sessionId: 'ses_root_unit_helper', + }) + }) + + it('exposes Codex session refs only after durable proof matches the resume id', () => { + expect(buildTerminalSessionRef({ + mode: 'codex', + resumeSessionId: 'thread-durable', + codexDurability: { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: 'thread-durable', + }, + })).toEqual({ + provider: 'codex', + sessionId: 'thread-durable', + }) + + expect(buildTerminalSessionRef({ + mode: 'codex', + resumeSessionId: 'thread-durable', + codexDurability: { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'durable', + durableThreadId: 'thread-other', + }, + })).toBeUndefined() + + expect(buildTerminalSessionRef({ + mode: 'codex', + resumeSessionId: 'thread-durable', + codexDurability: { + schemaVersion: CODEX_DURABILITY_SCHEMA_VERSION, + state: 'proof_checking', + candidate: { + provider: 'codex', + candidateThreadId: 'thread-durable', + rolloutPath: '/tmp/codex-rollout.jsonl', + source: 'restored_client_state', + capturedAt: 1, + }, + }, + })).toBeUndefined() + }) + + it('does not expose session refs for shell or records without a resume id', () => { + expect(buildTerminalSessionRef({ + mode: 'shell', + resumeSessionId: 'ignored', + })).toBeUndefined() + + expect(buildTerminalSessionRef({ + mode: 'opencode', + })).toBeUndefined() + }) +}) + function expectCodexMcpArgs(args: string[]) { expect(args).toContain('tui.notification_method=bel') expect(args).toContain("tui.notifications=['agent-turn-complete']") From e70c18fef6a0f41b547bc15caf6d2c5551ba2ff8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:38:13 -0700 Subject: [PATCH 06/11] fix: reconcile terminal session refs centrally --- src/App.tsx | 25 +++ src/components/TerminalView.tsx | 94 +++++----- src/lib/terminal-session-association.ts | 141 +++++++++++++++ src/store/panesSlice.ts | 51 ++++++ .../components/App.ws-bootstrap.test.tsx | 170 ++++++++++++++++++ .../TerminalView.resumeSession.test.tsx | 87 +++++++++ 6 files changed, 524 insertions(+), 44 deletions(-) create mode 100644 src/lib/terminal-session-association.ts diff --git a/src/App.tsx b/src/App.tsx index c31ca209..9652482c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { updateSettingsLocal } from '@/store/settingsSlice' import { setTerminalMetaSnapshot, upsertTerminalMeta, removeTerminalMeta } from '@/store/terminalMetaSlice' import { clearDeadTerminals } from '@/store/panesSlice' import { addTerminalFreshRecoveryRequestId, addTerminalRestoreRequestId } from '@/lib/terminal-restore' +import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' import { setCodexActivitySnapshot, upsertCodexActivity, removeCodexActivity, resetCodexActivity } from '@/store/codexActivitySlice' import { setClaudeActivitySnapshot, upsertClaudeActivity, removeClaudeActivity, resetClaudeActivity } from '@/store/claudeActivitySlice' import { setOpencodeActivitySnapshot, upsertOpencodeActivity, removeOpencodeActivity, resetOpencodeActivity } from '@/store/opencodeActivitySlice' @@ -855,6 +856,20 @@ export default function App() { if (terminalInvalidationHandler.handle(msg as any)) { return } + if ( + (msg.type === 'terminal.session.associated' + || msg.type === 'terminal.created' + || msg.type === 'terminal.attach.ready') + && typeof (msg as any).terminalId === 'string' + && (msg as any).sessionRef + ) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: (msg as any).terminalId, + sessionRef: (msg as any).sessionRef, + }) + } if (msg.type === 'terminal.inventory') { const terminals = Array.isArray(msg.terminals) ? msg.terminals : [] const terminalMeta = Array.isArray(msg.terminalMeta) ? msg.terminalMeta : [] @@ -871,6 +886,16 @@ export default function App() { && !(typeof record?.updatedAt === 'number' && record.updatedAt > terminalMetaRequestedAt) )) .map(([terminalId]) => terminalId) + for (const terminal of terminals) { + if (terminal?.terminalId && terminal?.sessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: terminal.terminalId, + sessionRef: terminal.sessionRef, + }) + } + } const liveIds = terminals .filter((t: any) => t.status === 'running') .map((t: any) => t.terminalId as string) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 4d9a7360..62c2ffdd 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -10,7 +10,7 @@ import { type TouchEvent as ReactTouchEvent, } from 'react' import { shallowEqual } from 'react-redux' -import { useAppDispatch, useAppSelector } from '@/store/hooks' +import { useAppDispatch, useAppSelector, useAppStore } from '@/store/hooks' import { updateTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' import { consumePaneRefreshRequest, splitPane, updatePaneContent, updatePaneTitle } from '@/store/panesSlice' import { updateSessionActivity } from '@/store/sessionActivitySlice' @@ -20,10 +20,11 @@ import { clearPaneRuntimeActivity } from '@/store/paneRuntimeActivitySlice' import { recordTurnComplete, clearTabAttention, clearPaneAttention } from '@/store/turnCompletionSlice' import { focusNextTerminalSearchMatch, focusPreviousTerminalSearchMatch, loadTerminalSearch } from '@/store/terminalDirectoryThunks' import { isFatalConnectionErrorCode } from '@/store/connectionSlice' -import { buildTerminalDurableSessionRefUpdate, flushPersistedLayoutNow } from '@/store/persistControl' +import { flushPersistedLayoutNow } from '@/store/persistControl' import { getWsClient } from '@/lib/ws-client' import { getTerminalTheme } from '@/lib/terminal-themes' import { getCreateSessionStateFromRef } from '@/components/terminal-view-utils' +import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' import { copyText, readText } from '@/lib/clipboard' import { registerTerminalActions } from '@/lib/pane-action-registry' import { registerTerminalCaptureHandler } from '@/lib/screenshot-capture-env' @@ -333,6 +334,7 @@ function resolveMobileToolbarInput(keyId: Exclude, c function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) { const dispatch = useAppDispatch() + const appStore = useAppStore() const isMobile = useMobile() const connectionStatus = useAppSelector((s) => s.connection.status) const serverInstanceId = useAppSelector((s) => s.connection.serverInstanceId) @@ -2137,6 +2139,16 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } } + const attachSessionRef = (msg as { sessionRef?: TerminalPaneContent['sessionRef'] }).sessionRef + if (attachSessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: tid, + sessionRef: attachSessionRef, + }) + } + const nextSeqState = onAttachReady(seqStateRef.current, { headSeq: msg.headSeq, replayFromSeq: msg.replayFromSeq, @@ -2187,14 +2199,24 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) terminalId: newId, currentResumeSessionId: contentRef.current?.resumeSessionId, }) + const createdSessionRef = (msg as { sessionRef?: TerminalPaneContent['sessionRef'] }).sessionRef terminalIdRef.current = newId updateContent({ terminalId: newId, serverInstanceId: serverInstanceIdRef.current, status: 'running', + ...(createdSessionRef ? { sessionRef: createdSessionRef, resumeSessionId: undefined } : {}), ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), ...(msg.restoreError ? { restoreError: msg.restoreError } : {}), }) + if (createdSessionRef) { + reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, + terminalId: newId, + sessionRef: createdSessionRef, + }) + } // Also update tab status const currentTab = tabRef.current if (currentTab) { @@ -2295,51 +2317,35 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) // Handle one-time session association from the authoritative canonical sessionRef. if (msg.type === 'terminal.session.associated' && msg.terminalId === tid) { - const sessionRef = msg.sessionRef - if (!sessionRef?.provider || !sessionRef?.sessionId) { - return - } - if (debugRef.current) log.debug('[TRACE resumeSessionId] terminal.session.associated', { - paneId: paneIdRef.current, + const reconciled = reconcileTerminalSessionAssociation({ + dispatch, + getState: appStore.getState, terminalId: tid, - oldResumeSessionId: contentRef.current?.resumeSessionId, - sessionRef, + sessionRef: msg.sessionRef, }) - const currentTab = tabHasSinglePaneRef.current ? tabRef.current : undefined - const durableIdentityUpdate = buildTerminalDurableSessionRefUpdate({ - provider: sessionRef.provider, - sessionId: sessionRef.sessionId, - paneSessionRef: contentRef.current?.sessionRef, - tabSessionRef: currentTab?.sessionRef, - 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, codexDurability: nextPaneCodexDurability }) - } - if (currentTab && durableIdentityUpdate?.tabUpdates) { - dispatch(updateTab({ id: currentTab.id, updates: { ...durableIdentityUpdate.tabUpdates, codexDurability: nextTabCodexDurability } })) + if (debugRef.current && reconciled) { + log.debug('[TRACE resumeSessionId] terminal.session.associated reconciled', { + paneId: paneIdRef.current, + terminalId: tid, + sessionRef: msg.sessionRef, + }) } - if (durableIdentityUpdate?.shouldFlush) { - dispatch(flushPersistedLayoutNow()) + if (reconciled && contentRef.current) { + const current = contentRef.current + const nextCodexDurability = msg.sessionRef.provider === 'codex' + && current.codexDurability?.state === 'durable' + && ( + current.codexDurability.durableThreadId === msg.sessionRef.sessionId + || current.codexDurability.candidate?.candidateThreadId === msg.sessionRef.sessionId + ) + ? current.codexDurability + : undefined + contentRef.current = { + ...current, + sessionRef: msg.sessionRef, + resumeSessionId: undefined, + ...(msg.sessionRef.provider === 'codex' ? { codexDurability: nextCodexDurability } : {}), + } } } diff --git a/src/lib/terminal-session-association.ts b/src/lib/terminal-session-association.ts new file mode 100644 index 00000000..1d2ede68 --- /dev/null +++ b/src/lib/terminal-session-association.ts @@ -0,0 +1,141 @@ +import { updateTab } from '@/store/tabsSlice' +import { reconcileTerminalSessionRefByTerminalId } from '@/store/panesSlice' +import { + buildTerminalDurableSessionRefUpdate, + flushPersistedLayoutNow, +} from '@/store/persistControl' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import type { RootState } from '@/store/store' +import type { CodingCliProviderName } from '@/store/types' +import { sanitizeSessionRef, type SessionRef } from '@shared/session-contract' + +type Dispatch = (action: any) => unknown +type SessionAssociationState = Pick + +function collectMatchingTerminalPanes( + node: PaneNode | undefined, + terminalId: string, + out: Array<{ paneId: string; content: TerminalPaneContent }>, +): void { + if (!node) return + if (node.type === 'leaf') { + if (node.content.kind === 'terminal' && node.content.terminalId === terminalId) { + out.push({ paneId: node.id, content: node.content }) + } + return + } + collectMatchingTerminalPanes(node.children[0], terminalId, out) + collectMatchingTerminalPanes(node.children[1], terminalId, out) +} + +function isSinglePaneTerminalMatch( + layout: PaneNode | undefined, + terminalId: string, +): layout is Extract { + return Boolean( + layout + && layout.type === 'leaf' + && layout.content.kind === 'terminal' + && layout.content.terminalId === terminalId, + ) +} + +function sessionRefsEqual(left?: SessionRef, right?: SessionRef): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} + +function terminalPaneNeedsDurableIdentityUpdate(content: TerminalPaneContent, sessionRef: SessionRef): boolean { + if (!sessionRefsEqual(content.sessionRef, sessionRef)) return true + if (typeof content.resumeSessionId === 'string') return true + if ( + sessionRef.provider === 'codex' + && !( + content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ) + ) { + return content.codexDurability !== undefined + } + return false +} + +export function reconcileTerminalSessionAssociation({ + dispatch, + getState, + terminalId, + sessionRef: rawSessionRef, +}: { + dispatch: Dispatch + getState: () => SessionAssociationState + terminalId?: string + sessionRef?: unknown +}): boolean { + if (!terminalId) return false + const sessionRef = sanitizeSessionRef(rawSessionRef) + if (!sessionRef) return false + + const state = getState() + let matchedAnyPane = false + let shouldFlush = false + const matchedSinglePaneTabs: Array<{ tabId: string; content: TerminalPaneContent }> = [] + for (const [tabId, layout] of Object.entries(state.panes.layouts)) { + const matches: Array<{ paneId: string; content: TerminalPaneContent }> = [] + collectMatchingTerminalPanes(layout, terminalId, matches) + if (matches.length === 0) continue + + matchedAnyPane = true + if (matches.some(({ content }) => terminalPaneNeedsDurableIdentityUpdate(content, sessionRef))) { + shouldFlush = true + } + if (isSinglePaneTerminalMatch(layout, terminalId)) { + matchedSinglePaneTabs.push({ tabId, content: matches[0].content }) + } + } + + if (!matchedAnyPane) return false + + dispatch(reconcileTerminalSessionRefByTerminalId({ terminalId, sessionRef })) + + for (const { tabId, content } of matchedSinglePaneTabs) { + const tab = state.tabs.tabs.find((candidate) => candidate.id === tabId) + if (!tab) continue + + const durableIdentityUpdate = buildTerminalDurableSessionRefUpdate({ + provider: sessionRef.provider as CodingCliProviderName, + sessionId: sessionRef.sessionId, + paneSessionRef: content.sessionRef, + tabSessionRef: tab.sessionRef, + paneResumeSessionId: content.resumeSessionId, + tabResumeSessionId: tab.resumeSessionId, + }) + const nextTabCodexDurability = sessionRef.provider === 'codex' + && tab.codexDurability?.state === 'durable' + && ( + tab.codexDurability.durableThreadId === sessionRef.sessionId + || tab.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ? tab.codexDurability + : undefined + const tabUpdates = { + ...(durableIdentityUpdate?.tabUpdates ?? {}), + ...(sessionRef.provider === 'codex' && tab.codexDurability !== nextTabCodexDurability + ? { codexDurability: nextTabCodexDurability } + : {}), + } + if (Object.keys(tabUpdates).length > 0) { + shouldFlush = true + dispatch(updateTab({ + id: tab.id, + updates: tabUpdates, + })) + } + } + + if (shouldFlush) { + dispatch(flushPersistedLayoutNow()) + } + return true +} diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 73388714..141703be 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -564,6 +564,10 @@ function clearRestoreFallbackAttemptForPane(state: PanesState, tabId: string, pa } } +function sessionRefsEqual(left?: { provider?: string; sessionId?: string }, right?: { provider?: string; sessionId?: string }): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} + function reconcileRefreshRequestsForTab(state: PanesState, tabId: string) { const tabRequests = state.refreshRequestsByPane?.[tabId] if (!tabRequests) return @@ -1575,6 +1579,52 @@ export const panesSlice = createSlice({ } }, + reconcileTerminalSessionRefByTerminalId: ( + state, + action: PayloadAction<{ terminalId: string; sessionRef: unknown }> + ) => { + const terminalId = action.payload.terminalId + const sessionRef = sanitizeSessionRef(action.payload.sessionRef) + if (!terminalId || !sessionRef) return + + function reconcileNode(node: PaneNode, tabId: string): void { + if (node.type === 'leaf') { + const content = node.content + if ( + content.kind !== 'terminal' + || content.terminalId !== terminalId + ) { + return + } + + if (!sessionRefsEqual(content.sessionRef, sessionRef)) { + content.sessionRef = sessionRef + } + content.resumeSessionId = undefined + if ( + sessionRef.provider === 'codex' + && !( + content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ) + ) { + content.codexDurability = undefined + } + clearRestoreFallbackAttemptForPane(state, tabId, node.id) + return + } + reconcileNode(node.children[0], tabId) + reconcileNode(node.children[1], tabId) + } + + for (const [tabId, layout] of Object.entries(state.layouts)) { + reconcileNode(layout, tabId) + } + }, + clearDeadTerminals: (state, action: PayloadAction<{ liveTerminalIds: string[] }>) => { const liveSet = new Set(action.payload.liveTerminalIds) @@ -1646,6 +1696,7 @@ export const { hydratePanes, updatePaneTitle, updatePaneTitleByTerminalId, + reconcileTerminalSessionRefByTerminalId, requestPaneRename, clearPaneRenameRequest, toggleZoom, diff --git a/test/unit/client/components/App.ws-bootstrap.test.tsx b/test/unit/client/components/App.ws-bootstrap.test.tsx index d10c09b2..8564a240 100644 --- a/test/unit/client/components/App.ws-bootstrap.test.tsx +++ b/test/unit/client/components/App.ws-bootstrap.test.tsx @@ -935,6 +935,176 @@ describe('App WS bootstrap recovery', () => { }) }) + it('recovers an OpenCode sessionRef from inventory before clearing a stale live handle', async () => { + const store = createStore({ + tabs: [{ + id: 'tab-opencode-refresh', + mode: 'opencode', + status: 'running', + resumeSessionId: 'legacy-title-like-id', + }], + panes: { + layouts: { + 'tab-opencode-refresh': { + type: 'leaf', + id: 'pane-opencode-refresh', + content: { + kind: 'terminal', + createRequestId: 'req-opencode-old', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-old', + resumeSessionId: 'legacy-title-like-id', + serverInstanceId: 'srv-old', + }, + }, + }, + activePane: { 'tab-opencode-refresh': 'pane-opencode-refresh' }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + const sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_inventory_refresh_restore', + } + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [{ + terminalId: 'term-opencode-old', + title: 'OpenCode', + mode: 'opencode', + createdAt: 1_000, + lastActivityAt: 1_700, + status: 'running', + sessionRef, + }], + terminalMeta: [], + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-refresh'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + + expect(content.terminalId).toBe('term-opencode-old') + expect(content.status).toBe('running') + expect(content.createRequestId).toBe('req-opencode-old') + expect(content.sessionRef).toEqual(sessionRef) + expect(content.resumeSessionId).toBeUndefined() + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-refresh')?.resumeSessionId).toBeUndefined() + }) + + act(() => { + messageHandler?.({ + type: 'terminal.inventory', + terminals: [], + terminalMeta: [], + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-refresh'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + + expect(content.terminalId).toBeUndefined() + expect(content.status).toBe('creating') + expect(content.createRequestId).not.toBe('req-opencode-old') + expect(content.sessionRef).toEqual(sessionRef) + expect(content.resumeSessionId).toBeUndefined() + expect(store.getState().panes.restoreFallbackAttemptsByPane?.['tab-opencode-refresh']?.['pane-opencode-refresh']).toBeUndefined() + expect(terminalRestoreMocks.addTerminalRestoreRequestId).toHaveBeenCalledWith(content.createRequestId) + expect(terminalRestoreMocks.addTerminalFreshRecoveryRequestId).not.toHaveBeenCalledWith( + content.createRequestId, + 'fresh_after_restore_unavailable', + ) + }) + }) + + it.each(['terminal.session.associated', 'terminal.attach.ready'] as const)( + 'persists OpenCode sessionRef from %s without TerminalView mounted', + async (type) => { + const store = createStore({ + tabs: [{ id: 'tab-opencode-associated', mode: 'opencode', status: 'running' }], + panes: { + layouts: { + 'tab-opencode-associated': { + type: 'leaf', + id: 'pane-opencode-associated', + content: { + kind: 'terminal', + createRequestId: 'req-opencode-associated', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-associated', + }, + }, + }, + activePane: { 'tab-opencode-associated': 'pane-opencode-associated' }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + + const sessionRef = { + provider: 'opencode', + sessionId: `ses_root_${type.replaceAll('.', '_')}`, + } + + act(() => { + messageHandler?.(type === 'terminal.session.associated' + ? { + type, + terminalId: 'term-opencode-associated', + sessionRef, + } + : { + type, + terminalId: 'term-opencode-associated', + headSeq: 0, + replayFromSeq: 1, + replayToSeq: 0, + sessionRef, + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-opencode-associated'] + if (!layout || layout.type !== 'leaf') throw new Error('expected leaf layout') + const content = layout.content + if (content.kind !== 'terminal') throw new Error('expected terminal pane') + expect(content.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-associated')?.sessionRef).toEqual(sessionRef) + }) + }, + ) + it('mounts with legacy ws clients that do not implement onDisconnect', async () => { const store = createStore() const originalOnDisconnect = wsMocks.onDisconnect diff --git a/test/unit/client/components/TerminalView.resumeSession.test.tsx b/test/unit/client/components/TerminalView.resumeSession.test.tsx index a3bfb931..66e6c3ab 100644 --- a/test/unit/client/components/TerminalView.resumeSession.test.tsx +++ b/test/unit/client/components/TerminalView.resumeSession.test.tsx @@ -240,6 +240,93 @@ describe('TerminalView durable session contract', () => { }) }) + it('persists canonical sessionRef from terminal.created when the server replays it', 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 sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_created_replay', + } + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-created-replay', + status: 'creating', + mode: 'opencode', + shell: 'system', + initialCwd: '/tmp', + } + 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-created-replay', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' }, + connection: { status: 'connected', error: null, serverInstanceId: 'srv-local' }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + requestId: 'req-created-replay', + })) + }) + + messageHandler?.({ + type: 'terminal.created', + requestId: 'req-created-replay', + terminalId: 'term-created-replay', + sessionRef, + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts[tabId] + if (layout?.type !== 'leaf') throw new Error('unexpected layout') + if (layout.content.kind !== 'terminal') throw new Error('unexpected content') + expect(layout.content.terminalId).toBe('term-created-replay') + expect(layout.content.sessionRef).toEqual(sessionRef) + expect(layout.content.resumeSessionId).toBeUndefined() + + const tab = store.getState().tabs.tabs.find((entry) => entry.id === tabId) + expect(tab?.sessionRef).toEqual(sessionRef) + expect(tab?.resumeSessionId).toBeUndefined() + }) + }) + it('persists canonical durable sessionRef only after terminal.session.associated', async () => { const tabId = 'tab-1' const paneId = 'pane-1' From 662ce92919de74e8d60afa0a33d035bc8805cb1b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:39:37 -0700 Subject: [PATCH 07/11] test: cover opencode restore after inventory identity recovery --- test/e2e/terminal-restart-recovery.test.tsx | 55 ++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/test/e2e/terminal-restart-recovery.test.tsx b/test/e2e/terminal-restart-recovery.test.tsx index 1da5d0e0..c6b8c6f6 100644 --- a/test/e2e/terminal-restart-recovery.test.tsx +++ b/test/e2e/terminal-restart-recovery.test.tsx @@ -3,7 +3,7 @@ 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 panesReducer, { clearDeadTerminals, reconcileTerminalSessionRefByTerminalId } from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import { useAppSelector } from '@/store/hooks' @@ -336,4 +336,57 @@ describe('terminal restart recovery (e2e)', () => { expect(sentMessages().filter((msg) => msg?.type === 'terminal.create')).toHaveLength(0) }) }) + + it('restores an OpenCode pane after inventory recovers a missing sessionRef before stale-handle cleanup', async () => { + const paneId = 'pane-codex' + const layout: PaneNode = { + type: 'leaf', + id: paneId, + content: { + kind: 'terminal', + createRequestId: 'req-opencode-old', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-old', + serverInstanceId: 'srv-old', + } satisfies TerminalPaneContent, + } + const store = createStore(layout) + + render( + + + , + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-opencode-old')).toBe(true) + }) + + wsHarness.send.mockClear() + store.dispatch(reconcileTerminalSessionRefByTerminalId({ + terminalId: 'term-opencode-old', + sessionRef: { + provider: 'opencode', + sessionId: 'ses_root_recovered_before_dead_clear', + }, + })) + store.dispatch(clearDeadTerminals({ liveTerminalIds: [] })) + registerRecoveryRequestsFromState(store) + + await waitFor(() => { + const create = sentMessages().find((msg) => msg?.type === 'terminal.create') + expect(create).toMatchObject({ + type: 'terminal.create', + mode: 'opencode', + restore: true, + sessionRef: { + provider: 'opencode', + sessionId: 'ses_root_recovered_before_dead_clear', + }, + }) + expect(create).not.toHaveProperty('recoveryIntent') + }) + }) }) From a28c52f5506348777aacf83cac8fe985aac31750 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:42:03 -0700 Subject: [PATCH 08/11] fix: clear stale codex durability on session ref reconciliation --- src/lib/terminal-session-association.ts | 16 +++++++--------- src/store/panesSlice.ts | 14 ++++++-------- .../components/App.ws-bootstrap.test.tsx | 18 +++++++++++++++++- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/lib/terminal-session-association.ts b/src/lib/terminal-session-association.ts index 1d2ede68..a9bd236a 100644 --- a/src/lib/terminal-session-association.ts +++ b/src/lib/terminal-session-association.ts @@ -47,16 +47,14 @@ function sessionRefsEqual(left?: SessionRef, right?: SessionRef): boolean { function terminalPaneNeedsDurableIdentityUpdate(content: TerminalPaneContent, sessionRef: SessionRef): boolean { if (!sessionRefsEqual(content.sessionRef, sessionRef)) return true if (typeof content.resumeSessionId === 'string') return true - if ( + if (!( sessionRef.provider === 'codex' - && !( - content.codexDurability?.state === 'durable' - && ( - content.codexDurability.durableThreadId === sessionRef.sessionId - || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId - ) + && content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId ) - ) { + )) { return content.codexDurability !== undefined } return false @@ -121,7 +119,7 @@ export function reconcileTerminalSessionAssociation({ : undefined const tabUpdates = { ...(durableIdentityUpdate?.tabUpdates ?? {}), - ...(sessionRef.provider === 'codex' && tab.codexDurability !== nextTabCodexDurability + ...(tab.codexDurability !== nextTabCodexDurability ? { codexDurability: nextTabCodexDurability } : {}), } diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 141703be..8ef07001 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -1601,16 +1601,14 @@ export const panesSlice = createSlice({ content.sessionRef = sessionRef } content.resumeSessionId = undefined - if ( + if (!( sessionRef.provider === 'codex' - && !( - content.codexDurability?.state === 'durable' - && ( - content.codexDurability.durableThreadId === sessionRef.sessionId - || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId - ) + && content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId ) - ) { + )) { content.codexDurability = undefined } clearRestoreFallbackAttemptForPane(state, tabId, node.id) diff --git a/test/unit/client/components/App.ws-bootstrap.test.tsx b/test/unit/client/components/App.ws-bootstrap.test.tsx index 8564a240..230fe1d8 100644 --- a/test/unit/client/components/App.ws-bootstrap.test.tsx +++ b/test/unit/client/components/App.ws-bootstrap.test.tsx @@ -1042,7 +1042,16 @@ describe('App WS bootstrap recovery', () => { 'persists OpenCode sessionRef from %s without TerminalView mounted', async (type) => { const store = createStore({ - tabs: [{ id: 'tab-opencode-associated', mode: 'opencode', status: 'running' }], + tabs: [{ + id: 'tab-opencode-associated', + mode: 'opencode', + status: 'running', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'stale-codex-thread', + }, + }], panes: { layouts: { 'tab-opencode-associated': { @@ -1055,6 +1064,11 @@ describe('App WS bootstrap recovery', () => { mode: 'opencode', shell: 'system', terminalId: 'term-opencode-associated', + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'stale-codex-thread', + }, }, }, }, @@ -1100,7 +1114,9 @@ describe('App WS bootstrap recovery', () => { const content = layout.content if (content.kind !== 'terminal') throw new Error('expected terminal pane') expect(content.sessionRef).toEqual(sessionRef) + expect(content.codexDurability).toBeUndefined() expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-associated')?.sessionRef).toEqual(sessionRef) + expect(store.getState().tabs.tabs.find((tab) => tab.id === 'tab-opencode-associated')?.codexDurability).toBeUndefined() }) }, ) From 2d07ddf25dae12e8886a276d20f558f5f5099503 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:43:26 -0700 Subject: [PATCH 09/11] fix: sync terminal view session durability ref --- src/components/TerminalView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 62c2ffdd..52e7d902 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -2344,7 +2344,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) ...current, sessionRef: msg.sessionRef, resumeSessionId: undefined, - ...(msg.sessionRef.provider === 'codex' ? { codexDurability: nextCodexDurability } : {}), + codexDurability: nextCodexDurability, } } } From 76ccff3f1f73094cd2dd1d5bd9be5c92aedb63bf Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:51:35 -0700 Subject: [PATCH 10/11] fix: keep terminal session replay refs current --- src/components/TerminalView.tsx | 68 ++++++++---- .../TerminalView.lifecycle.test.tsx | 101 ++++++++++++++++++ 2 files changed, 149 insertions(+), 20 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 52e7d902..ec7027fe 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -99,7 +99,7 @@ import { scrollLinesToCursorKeys, shouldTranslateScrollToCursorKeys, } from '@/lib/terminal-behavior' -import { buildRestoreError } from '@shared/session-contract' +import { buildRestoreError, sanitizeSessionRef } from '@shared/session-contract' const log = createLogger('TerminalView') @@ -125,6 +125,29 @@ function viewportHydrateReplayOptions(content?: TerminalPaneContent | null): { m : { maxReplayBytes: TRUNCATED_REPLAY_BYTES } } +function buildSessionAssociationContentUpdates( + content: TerminalPaneContent | null | undefined, + rawSessionRef: unknown, +): Pick | undefined { + const sessionRef = sanitizeSessionRef(rawSessionRef) + if (!content || !sessionRef) return undefined + + const codexDurability = sessionRef.provider === 'codex' + && content.codexDurability?.state === 'durable' + && ( + content.codexDurability.durableThreadId === sessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + ) + ? content.codexDurability + : undefined + + return { + sessionRef, + resumeSessionId: undefined, + codexDurability, + } +} + type TerminalInputBlockedReason = | 'codex_identity_pending' | 'codex_identity_capture_timeout' @@ -857,6 +880,17 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) })) }, [dispatch, tabId, paneId]) // NO terminalContent dependency - uses ref + const syncContentRefWithSessionAssociation = useCallback((rawSessionRef: unknown) => { + const current = contentRef.current + const updates = buildSessionAssociationContentUpdates(current, rawSessionRef) + if (!current || !updates) return false + contentRef.current = { + ...current, + ...updates, + } + return true + }, []) + const requestTerminalLayout = useCallback((options: { fit?: boolean resize?: boolean @@ -2141,12 +2175,15 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const attachSessionRef = (msg as { sessionRef?: TerminalPaneContent['sessionRef'] }).sessionRef if (attachSessionRef) { - reconcileTerminalSessionAssociation({ + const reconciled = reconcileTerminalSessionAssociation({ dispatch, getState: appStore.getState, terminalId: tid, sessionRef: attachSessionRef, }) + if (reconciled) { + syncContentRefWithSessionAssociation(attachSessionRef) + } } const nextSeqState = onAttachReady(seqStateRef.current, { @@ -2200,22 +2237,26 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) currentResumeSessionId: contentRef.current?.resumeSessionId, }) const createdSessionRef = (msg as { sessionRef?: TerminalPaneContent['sessionRef'] }).sessionRef + const createdSessionUpdates = buildSessionAssociationContentUpdates(contentRef.current, createdSessionRef) terminalIdRef.current = newId updateContent({ terminalId: newId, serverInstanceId: serverInstanceIdRef.current, status: 'running', - ...(createdSessionRef ? { sessionRef: createdSessionRef, resumeSessionId: undefined } : {}), + ...(createdSessionUpdates ?? {}), ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), ...(msg.restoreError ? { restoreError: msg.restoreError } : {}), }) if (createdSessionRef) { - reconcileTerminalSessionAssociation({ + const reconciled = reconcileTerminalSessionAssociation({ dispatch, getState: appStore.getState, terminalId: newId, sessionRef: createdSessionRef, }) + if (reconciled) { + syncContentRefWithSessionAssociation(createdSessionRef) + } } // Also update tab status const currentTab = tabRef.current @@ -2330,22 +2371,8 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) sessionRef: msg.sessionRef, }) } - if (reconciled && contentRef.current) { - const current = contentRef.current - const nextCodexDurability = msg.sessionRef.provider === 'codex' - && current.codexDurability?.state === 'durable' - && ( - current.codexDurability.durableThreadId === msg.sessionRef.sessionId - || current.codexDurability.candidate?.candidateThreadId === msg.sessionRef.sessionId - ) - ? current.codexDurability - : undefined - contentRef.current = { - ...current, - sessionRef: msg.sessionRef, - resumeSessionId: undefined, - codexDurability: nextCodexDurability, - } + if (reconciled) { + syncContentRefWithSessionAssociation(msg.sessionRef) } } @@ -2666,6 +2693,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) markAttachComplete, resetStartupProbeParser, runRefreshAttach, + syncContentRefWithSessionAssociation, ]) useEffect(() => { diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index fe857994..c0b270b3 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -231,6 +231,10 @@ function withCurrentAttachRequestId msg) +} + describe('TerminalView lifecycle updates', () => { let messageHandler: ((msg: any) => void) | null = null let reconnectHandler: (() => void) | null = null @@ -2144,6 +2148,103 @@ describe('TerminalView lifecycle updates', () => { }) }) + it('uses sessionRef replayed by terminal.attach.ready for an immediate invalid-terminal reconnect', async () => { + const tabId = 'tab-opencode-attach-ready-replay' + const paneId = 'pane-opencode-attach-ready-replay' + const sessionRef = { + provider: 'opencode', + sessionId: 'ses_root_attach_ready_replay', + } + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-opencode-attach-ready-replay', + status: 'running', + mode: 'opencode', + shell: 'system', + terminalId: 'term-opencode-attach-ready-replay', + 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, + terminalId: 'term-opencode-attach-ready-replay', + createRequestId: 'req-opencode-attach-ready-replay', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: createSettingsState(), + connection: { status: 'connected', error: null, serverInstanceId: 'srv-new' }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => ( + msg?.type === 'terminal.attach' + && msg.terminalId === 'term-opencode-attach-ready-replay' + ))).toBe(true) + }) + + restoreMocks.addTerminalRestoreRequestId.mockClear() + restoreMocks.addTerminalFreshRecoveryRequestId.mockClear() + + act(() => { + messageHandler!({ + type: 'terminal.attach.ready', + terminalId: 'term-opencode-attach-ready-replay', + headSeq: 0, + replayFromSeq: 1, + replayToSeq: 0, + sessionRef, + }) + messageHandler!({ + type: 'error', + code: 'INVALID_TERMINAL_ID', + message: 'Unknown terminalId', + terminalId: 'term-opencode-attach-ready-replay', + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts[tabId] + if (layout?.type !== 'leaf') throw new Error('unexpected layout') + if (layout.content.kind !== 'terminal') throw new Error('unexpected content') + expect(layout.content.terminalId).toBeUndefined() + expect(layout.content.status).toBe('creating') + expect(layout.content.sessionRef).toEqual(sessionRef) + expect(layout.content.createRequestId).not.toBe('req-opencode-attach-ready-replay') + expect(restoreMocks.addTerminalRestoreRequestId).toHaveBeenCalledWith(layout.content.createRequestId) + }) + expect(restoreMocks.addTerminalFreshRecoveryRequestId).not.toHaveBeenCalled() + }) + it('does not reconnect when a restored launch fails before the first attach completes', async () => { const tabId = 'tab-restore-startup-failure' const paneId = 'pane-restore-startup-failure' From 702dbe31e017e10bab353452e54d0f92e3c4ac67 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 02:59:08 -0700 Subject: [PATCH 11/11] fix: preserve narrowed session ref in pane reconciliation --- src/store/panesSlice.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 8ef07001..e6067e2d 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -1586,6 +1586,7 @@ export const panesSlice = createSlice({ const terminalId = action.payload.terminalId const sessionRef = sanitizeSessionRef(action.payload.sessionRef) if (!terminalId || !sessionRef) return + const canonicalSessionRef = sessionRef function reconcileNode(node: PaneNode, tabId: string): void { if (node.type === 'leaf') { @@ -1597,16 +1598,16 @@ export const panesSlice = createSlice({ return } - if (!sessionRefsEqual(content.sessionRef, sessionRef)) { - content.sessionRef = sessionRef + if (!sessionRefsEqual(content.sessionRef, canonicalSessionRef)) { + content.sessionRef = canonicalSessionRef } content.resumeSessionId = undefined if (!( - sessionRef.provider === 'codex' + canonicalSessionRef.provider === 'codex' && content.codexDurability?.state === 'durable' && ( - content.codexDurability.durableThreadId === sessionRef.sessionId - || content.codexDurability.candidate?.candidateThreadId === sessionRef.sessionId + content.codexDurability.durableThreadId === canonicalSessionRef.sessionId + || content.codexDurability.candidate?.candidateThreadId === canonicalSessionRef.sessionId ) )) { content.codexDurability = undefined