From 9d7f6a5f2eb47da3add712c0716044967514d924 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 16:46:36 -0700 Subject: [PATCH 1/3] docs: plan OpenCode hidden association restore --- ...-01-opencode-hidden-association-restore.md | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md diff --git a/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md b/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md new file mode 100644 index 00000000..83c7c25e --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md @@ -0,0 +1,404 @@ +# OpenCode Hidden Association 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:** Ship a tested fix for OpenCode tabs that fail to restore after browser refresh/reopen when the server learns the OpenCode session association while the browser page is closed or the OpenCode pane is hidden. + +**Current State:** The production reconciliation code from PR #380 is already merged into `origin/main`. Do not recreate it. This plan implements and lands the missing browser-level regression coverage that proves the real failure red on a pre-fix base and green on the current branch. + +**Architecture:** The server remains authoritative for terminal-to-session ownership. The browser reconciles every authoritative `{ terminalId, sessionRef }` association from WebSocket events and `/api/terminals` inventory into pane layout and single-pane tab metadata, then flushes durable layout state. Hidden panes must be reconciled even when `TerminalView` is unmounted. + +**Tech Stack:** React 18, Redux Toolkit, TypeScript, WebSocket messages, Playwright browser E2E, Node fake OpenCode fixture. + +--- + +## Scope + +This plan covers the OpenCode refresh/reopen failure only. It does not add sidebar project/session search matching, generic `resumeSessionId` fallback, or new end-user features. + +The remaining code changes should be limited to: + +- `test/e2e-browser/fixtures/fake-opencode.cjs` +- `test/e2e-browser/specs/opencode-restart-recovery.spec.ts` +- This plan file, if it needs execution notes updated + +The production reconciliation files should be verified, not edited, unless the audit in Task 1 proves the current branch is missing code: + +- `src/lib/terminal-session-association.ts` +- `src/store/panesSlice.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` + +If any production reconciliation prerequisite is missing, stop and update this plan before implementing. Do not add duplicate reducers, imports, WebSocket handlers, helper files, or tests. + +--- + +## Task 1: Audit The Existing Production Fix + +Purpose: confirm the branch already has the merged production behavior that the E2E regression is meant to protect. + +- [ ] Run: + +```bash +git status --short --branch +git rev-list --left-right --count origin/main...HEAD +``` + +Expected: + +- The branch may be ahead by plan/test commits. +- It must include `origin/main`. +- There should be no unrelated production edits. + +- [ ] Verify the shared reconciliation helper exists: + +```bash +rg -n "export function reconcileTerminalSessionAssociation|sessionRefByTerminalId|flushPersistedLayoutNow" src/lib/terminal-session-association.ts +``` + +Expected: + +- The file exists. +- It exports `reconcileTerminalSessionAssociation`. +- It can reconcile a terminal by `terminalId`. +- It flushes persistence after updating layout state. + +- [ ] Verify the pane reducer exists exactly once: + +```bash +rg -n "reconcileTerminalSessionRefByTerminalId" src/store/panesSlice.ts +``` + +Expected: + +- Exactly one reducer definition. +- Exactly one generated action export or action usage for that reducer. + +- [ ] Verify App-level authoritative association replay: + +```bash +rg -n "reconcileTerminalSessionAssociation|terminal\\.session\\.associated|terminal\\.attach\\.ready|terminal\\.created|terminal\\.inventory" src/App.tsx +``` + +Expected: + +- `App.tsx` imports `reconcileTerminalSessionAssociation`. +- `terminal.created`, `terminal.attach.ready`, and `terminal.session.associated` route server-provided session refs through the helper. +- Startup terminal inventory routes existing server session refs through the helper. + +- [ ] Verify active `TerminalView` code uses the same helper: + +```bash +rg -n "reconcileTerminalSessionAssociation|terminal\\.created|terminal\\.session\\.associated|terminal\\.attach\\.ready" src/components/TerminalView.tsx +``` + +Expected: + +- `TerminalView.tsx` imports `reconcileTerminalSessionAssociation`. +- Active pane events use the same helper as hidden pane events. + +- [ ] Verify existing unit coverage for the production reconciliation: + +```bash +rg -n "recovers an OpenCode sessionRef from inventory|terminal\\.session\\.associated|terminal\\.attach\\.ready|tracks OpenCode session associations|uses associated OpenCode session identity" test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx +``` + +Expected: + +- App tests cover inventory and WebSocket association replay. +- TerminalView tests cover active pane association behavior. + +Do not add new unit tests in this task unless one of the expected existing tests is absent. If a test is absent, first inspect the current file harness and add a test using the actual local helper APIs in that file. + +--- + +## Task 2: Add The Fake OpenCode Session Event Gate + +Purpose: allow the browser E2E test to create a real OpenCode terminal, send input to it, close the page before the server learns the canonical session, then release the delayed OpenCode session events. + +File: `test/e2e-browser/fixtures/fake-opencode.cjs` + +- [ ] Add these environment controls near the existing `sessionArg` setup: + +```js +const sessionEventGatePath = process.env.FAKE_OPENCODE_SESSION_EVENT_GATE_PATH +const sessionEventDelayMs = Number(process.env.FAKE_OPENCODE_SESSION_EVENT_DELAY_MS || '100') +``` + +- [ ] Add `emitSessionEvents(res)` and `scheduleSessionEvents(res)` near the SSE event-client setup. + +Required behavior: + +- Without `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH`, preserve existing behavior and emit `session.created` plus `session.idle` after a short delay. +- With `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH`, poll for that file and emit events only after it exists. +- If the response is destroyed before release, stop polling. +- Append one audit event when the session events are emitted: + +```js +appendAudit({ + event: 'session_events_emitted', + rootSessionId, + childSessionId, +}) +``` + +- [ ] Replace the existing inline `setTimeout` in the `/event` route with: + +```js +scheduleSessionEvents(res) +``` + +- [ ] Keep launch and stdin audit behavior unchanged. The regression test depends on the launch audit having no `sessionArg` and on stdin audit records being keyed to the root session id. + +--- + +## Task 3: Add Browser E2E Coverage For Refresh Survival + +Purpose: prove the actual user-visible behavior with browser state, server inventory, tab persistence, and real fake-OpenCode process IO. + +File: `test/e2e-browser/specs/opencode-restart-recovery.spec.ts` + +- [ ] Extend `createServerOptions` with optional `fakeOpencodeSessionEventGatePath` and pass it as `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH` when provided. + +- [ ] Add: + +```ts +const TAB_REGISTRY_SYNC_INTERVAL_MS = 5000 +``` + +Use this constant only where the test must wait for the tab-registry sync loop. + +- [ ] Preserve or add the UI refresh helper: + +```ts +async function addOpenCodeTabThroughUi(page: any, cwd: string): Promise +``` + +Required behavior: + +- Creates an OpenCode tab through the real UI. +- Returns the created tab id. +- Avoids test-only direct Redux state setup for this UI-path test. + +- [ ] Preserve or add the audit helpers: + +```ts +async function waitForInitialOpenCodeLaunch(auditLogPath: string): Promise +async function waitForSessionEventsEmitted(auditLogPath: string, sessionId: string): Promise +``` + +Required behavior: + +- `waitForInitialOpenCodeLaunch` finds a launch event with no `sessionArg` and a root session id. +- `waitForSessionEventsEmitted` waits for the gate-release audit for the expected root session id. + +- [ ] Preserve or add this UI-path refresh test: + +```ts +test('reattaches a UI-created OpenCode pane across browser refresh', async ({ page }) => { + // ... +}) +``` + +Required proof: + +- Creates an OpenCode pane through the UI. +- Verifies the pane is associated with an OpenCode session. +- Refreshes the browser. +- Verifies the same pane/tab reattaches to the same OpenCode session. + +- [ ] Add the hidden association regression test: + +```ts +test('recovers a hidden OpenCode sessionRef when association lands while the browser is closed', async ({ page }) => { + // ... +}) +``` + +Required proof: + +- Starts an isolated `TestServer` with the fake OpenCode session-event gate. +- Creates an actual OpenCode tab through the E2E harness. +- Waits only for the terminal to be running and asserts the pane naturally has no `sessionRef` yet. +- Waits for the initial fake OpenCode launch audit and records its root session id. +- Sends input to the OpenCode terminal and verifies the fake process receives it for that root session. +- Creates a shell tab and makes it active so the OpenCode pane is hidden. +- Flushes persisted layout and asserts the persisted OpenCode tab and pane do not yet have `sessionRef`. +- Closes the browser page. +- Releases the fake OpenCode session event gate. +- Verifies `/api/terminals` reports the original terminal id now has `{ provider: 'opencode', sessionId: expectedRootSessionId }`. +- Opens a new page in the same browser context while the shell tab is active. +- Verifies the hidden OpenCode pane/tab regains the same `sessionRef` and terminal id from server inventory. +- Verifies `tabs.sync.push` sends that recovered `sessionRef`. +- Activates the OpenCode tab, sends input again, and verifies the same root session receives it. + +- [ ] Preserve or add this associated refresh test: + +```ts +test('preserves an associated OpenCode pane across browser refresh', async ({ page }) => { + // ... +}) +``` + +Required proof: + +- Starts with an already associated OpenCode pane. +- Refreshes the browser. +- Verifies the association survives and the pane remains usable. + +Do not assert a hard-coded total number of tests in this spec. The expected count is whatever Playwright reports after the current branch's spec contents are finalized. + +--- + +## Task 4: Prove The Regression Test Is Red On The Pre-Client-Reconciliation Base + +Purpose: prove the new hidden-association test reproduces the real browser failure before the client-side hidden-pane reconciliation fix, not just a synthetic missing-localStorage theory. + +- [ ] Create a temporary worktree at `8e1492b4`, the pre-client-reconciliation base used for this red proof: + +```bash +git worktree add /tmp/freshell-opencode-hidden-association-red 8e1492b4 +``` + +This base already contains the server-side session reference exposure from PR #380. That is intentional: the failure being reproduced is the browser failing to reconcile a server-known OpenCode `sessionRef` into a hidden pane. If `8e1492b4` is unavailable, use a commit after `/api/terminals` exposes OpenCode `sessionRef` but before `src/lib/terminal-session-association.ts` exists. + +- [ ] From the feature worktree, export only the E2E fixture/spec patch: + +```bash +git diff origin/main -- test/e2e-browser/fixtures/fake-opencode.cjs test/e2e-browser/specs/opencode-restart-recovery.spec.ts > /tmp/freshell-opencode-hidden-association-e2e.patch +``` + +- [ ] Apply that patch in the temporary red worktree: + +```bash +cd /tmp/freshell-opencode-hidden-association-red +git apply /tmp/freshell-opencode-hidden-association-e2e.patch +``` + +- [ ] Install dependencies in the temporary red worktree before running Playwright: + +```bash +cd /tmp/freshell-opencode-hidden-association-red +timeout 600s env NODE_ENV=development npm ci --include=dev +timeout 300s env NODE_ENV=development npx playwright install chromium +``` + +Expected: + +- Dependency installation completes successfully. +- The Chromium browser binary required by Playwright is present. +- If dependency installation fails, report that setup failure separately. Do not count it as the intended red test failure. + +- [ ] Run the focused hidden-association test: + +```bash +cd /tmp/freshell-opencode-hidden-association-red +timeout 420s npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts -g "recovers a hidden OpenCode sessionRef when association lands while the browser is closed" +``` + +Expected: + +- The test fails. +- The failure occurs after the server has the canonical OpenCode `sessionRef`. +- The browser does not restore the hidden pane/tab association. +- A timeout at the hidden-pane `waitForFunction` assertion is an acceptable red failure. + +- [ ] Capture the failing command and failure summary in the execution notes. + +- [ ] Remove the temporary worktree: + +```bash +cd /home/dan/code/freshell +git worktree remove --force /tmp/freshell-opencode-hidden-association-red +``` + +Do not leave the temp worktree or patch file as part of the branch. + +--- + +## Task 5: Prove The Regression Test Is Green On The Current Branch + +Purpose: prove the current production reconciliation plus the new browser coverage fixes the actual behavior. + +- [ ] Run the focused hidden-association test in the feature worktree: + +```bash +timeout 420s npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts -g "recovers a hidden OpenCode sessionRef when association lands while the browser is closed" +``` + +Expected: + +- The test passes. +- The logs show no server restart, broad process kill, or self-hosted dev-server restart. + +- [ ] Run the full OpenCode restart recovery spec: + +```bash +timeout 480s npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts +``` + +Expected: + +- All tests in the spec pass. +- Record the actual Playwright count from the output instead of assuming a fixed number. + +- [ ] Run focused unit coverage for the already-merged production reconciliation: + +```bash +timeout 240s npm run test:vitest -- test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/client/components/TerminalView.resumeSession.test.tsx --run +``` + +Expected: + +- The relevant App and TerminalView unit tests pass. + +- [ ] Run whitespace and diff sanity checks: + +```bash +git diff --check +git diff --stat origin/main +git diff --name-only origin/main +``` + +Expected: + +- No whitespace errors. +- Diff is limited to the fake fixture, E2E spec, and plan file unless a production prerequisite was actually missing and this plan was updated. + +--- + +## Task 6: Refactor And Finalize + +Purpose: keep the branch focused and shippable after the red/green proof. + +- [ ] Review the E2E helpers for duplication and naming clarity. Keep helpers local to `opencode-restart-recovery.spec.ts` unless another spec needs them now. + +- [ ] Confirm the fake fixture gate is opt-in. Existing tests must keep their old session-event timing when `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH` is unset. + +- [ ] Confirm no production code introduces or expands a fallback that guesses OpenCode sessions from sidebar/project metadata. + +- [ ] Confirm no README or `docs/index.html` change is needed. This is a correctness fix for existing behavior, not an end-user feature or major UI change. + +- [ ] Commit the final branch changes: + +```bash +git add test/e2e-browser/fixtures/fake-opencode.cjs test/e2e-browser/specs/opencode-restart-recovery.spec.ts docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md +git commit -m "test: cover OpenCode refresh session recovery" +``` + +If the plan file was already committed separately, amend or add a follow-up plan-fix commit according to the current branch history. Do not squash away useful red/green evidence unless the user asks. + +--- + +## Completion Evidence + +The executor must report: + +- The pre-fix red command and concise failure summary. +- The current-branch focused green command and result. +- The current-branch full OpenCode restart recovery spec command and result. +- The focused unit-test command and result. +- The final files changed. +- Any deviations from this plan and why they were necessary. From 60dcbd026e75a25008aa5e17b28bf5fa093d18e8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 21:21:08 -0700 Subject: [PATCH 2/3] test: cover OpenCode refresh session recovery --- test/e2e-browser/fixtures/fake-opencode.cjs | 66 ++- .../specs/opencode-restart-recovery.spec.ts | 436 ++++++++++++++++++ 2 files changed, 483 insertions(+), 19 deletions(-) diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index 02dc77a0..d2f866b2 100644 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -30,6 +30,8 @@ if (process.argv.includes('--version') || process.argv.includes('version')) { const hostname = argValue('--hostname') || '127.0.0.1' const port = Number(argValue('--port')) const sessionArg = argValue('--session') +const sessionEventGatePath = process.env.FAKE_OPENCODE_SESSION_EVENT_GATE_PATH +const sessionEventDelayMs = Number(process.env.FAKE_OPENCODE_SESSION_EVENT_DELAY_MS || '100') if (!Number.isInteger(port) || port <= 0 || port > 65535) { process.stdout.write('fake opencode: no server port requested\n') @@ -107,6 +109,50 @@ process.stdin.on('data', (data) => { }) const eventClients = new Set() + +function emitSessionEvents(res) { + if (res.destroyed) return + appendAudit({ + event: 'session_events_emitted', + rootSessionId, + childSessionId, + }) + res.write(`data: ${JSON.stringify({ + type: 'session.created', + properties: { + sessionID: childSessionId, + info: { + id: childSessionId, + parentID: rootSessionId, + }, + }, + })}\n\n`) + res.write(`data: ${JSON.stringify({ + type: 'session.idle', + properties: { + sessionID: childSessionId, + }, + })}\n\n`) +} + +function scheduleSessionEvents(res) { + if (sessionEventGatePath) { + const interval = setInterval(() => { + if (res.destroyed) { + clearInterval(interval) + return + } + if (!fs.existsSync(sessionEventGatePath)) return + clearInterval(interval) + emitSessionEvents(res) + }, 50) + interval.unref?.() + return + } + + setTimeout(() => emitSessionEvents(res), Number.isFinite(sessionEventDelayMs) ? Math.max(0, sessionEventDelayMs) : 100) +} + const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${hostname}:${port}`) if (url.pathname === '/global/health') { @@ -137,25 +183,7 @@ const server = http.createServer((req, res) => { }) eventClients.add(res) res.write(`data: ${JSON.stringify({ type: 'server.connected', properties: {} })}\n\n`) - setTimeout(() => { - if (res.destroyed) return - res.write(`data: ${JSON.stringify({ - type: 'session.created', - properties: { - sessionID: childSessionId, - info: { - id: childSessionId, - parentID: rootSessionId, - }, - }, - })}\n\n`) - res.write(`data: ${JSON.stringify({ - type: 'session.idle', - properties: { - sessionID: childSessionId, - }, - })}\n\n`) - }, 100) + scheduleSessionEvents(res) req.on('close', () => { eventClients.delete(res) }) diff --git a/test/e2e-browser/specs/opencode-restart-recovery.spec.ts b/test/e2e-browser/specs/opencode-restart-recovery.spec.ts index b50deeb1..7e9f28ee 100644 --- a/test/e2e-browser/specs/opencode-restart-recovery.spec.ts +++ b/test/e2e-browser/specs/opencode-restart-recovery.spec.ts @@ -9,6 +9,7 @@ import { TestServer } from '../helpers/test-server.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const fakeOpencodeSource = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') +const TAB_REGISTRY_SYNC_INTERVAL_MS = 5000 type RestartMode = 'graceful' | 'kill' @@ -54,6 +55,7 @@ function createServerOptions(input: { auditLogPath: string logsDir: string sharedOpencodeDataDir: string + fakeOpencodeSessionEventGatePath?: string port?: number token?: string }) { @@ -64,6 +66,7 @@ function createServerOptions(input: { env: { PATH: `${input.binDir}${path.delimiter}${process.env.PATH ?? ''}`, FAKE_OPENCODE_AUDIT_LOG: input.auditLogPath, + ...(input.fakeOpencodeSessionEventGatePath ? { FAKE_OPENCODE_SESSION_EVENT_GATE_PATH: input.fakeOpencodeSessionEventGatePath } : {}), FRESHELL_LOG_DIR: input.logsDir, }, } @@ -106,6 +109,26 @@ async function addTerminalTab(page: any, input: { }, input) } +async function addOpenCodeTabThroughUi(page: any, cwd: string): Promise { + await page.locator('[data-context="tab-add"]').click() + const picker = page.getByRole('toolbar', { name: /pane type picker/i }).last() + await expect(picker).toBeVisible({ timeout: 15_000 }) + await picker.getByRole('button', { name: /^OpenCode$/i }).click() + + const directoryInput = page.getByLabel(/^Starting directory for OpenCode$/i) + await expect(directoryInput).toBeVisible({ timeout: 15_000 }) + await directoryInput.fill(cwd) + await directoryInput.press('Enter') + + return page.evaluate(() => { + const tabId = window.__FRESHELL_TEST_HARNESS__?.getState()?.tabs?.activeTabId + if (typeof tabId !== 'string' || !tabId) { + throw new Error('No active tab after creating OpenCode tab through UI') + } + return tabId + }) +} + async function getPaneSnapshots(page: any, tabIds: string[]): Promise { return page.evaluate((ids) => { const state = window.__FRESHELL_TEST_HARNESS__?.getState() @@ -255,6 +278,39 @@ async function waitForRestoreLaunches( return { auditEvents: latestAuditEvents, restoreLaunches } } +async function waitForInitialOpenCodeLaunch(auditLogPath: string): Promise { + let latestAuditEvents: FakeAuditEvent[] = [] + await expect.poll(async () => { + latestAuditEvents = await readAuditEvents(auditLogPath) + const launch = latestAuditEvents.find((event) => + event.event === 'launch' + && !event.sessionArg + && typeof event.rootSessionId === 'string' + ) + return launch?.rootSessionId + }, { timeout: 15_000 }).toMatch(/^ses_root_/) + + const launch = latestAuditEvents.find((event) => + event.event === 'launch' + && !event.sessionArg + && typeof event.rootSessionId === 'string' + ) + if (!launch?.rootSessionId) { + throw new Error(`Missing initial OpenCode launch audit: ${JSON.stringify(latestAuditEvents, null, 2)}`) + } + return launch +} + +async function waitForSessionEventsEmitted(auditLogPath: string, sessionId: string): Promise { + await expect.poll(async () => { + const events = await readAuditEvents(auditLogPath) + return events.some((event) => + event.event === 'session_events_emitted' + && event.rootSessionId === sessionId + ) + }, { timeout: 15_000 }).toBe(true) +} + async function waitForStdinAudit( auditLogPath: string, expectedByTab: Array<{ tabId: string; sessionId: string }>, @@ -470,6 +526,386 @@ async function runRestartScenario(input: { test.describe('OpenCode restart recovery', () => { test.setTimeout(180_000) + test('reattaches a UI-created OpenCode pane across browser refresh', async ({ page }) => { + const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-ui-refresh-')) + const binDir = path.join(sharedRoot, 'bin') + const logsDir = path.join(sharedRoot, 'logs') + const auditLogPath = path.join(sharedRoot, 'fake-opencode-audit.jsonl') + const sharedOpencodeDataDir = path.join(sharedRoot, 'opencode-data') + await installFakeOpencode(binDir) + + const server = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + })) + + try { + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + for (let index = 1; index <= 13; index += 1) { + const shellTab = { + tabId: `tab-ui-refresh-shell-${index}`, + paneId: `pane-ui-refresh-shell-${index}`, + requestId: `req-ui-refresh-shell-${index}`, + mode: 'shell' as const, + title: `UI Refresh Shell ${index}`, + } + await addTerminalTab(page, shellTab) + await waitForRunningTerminals(page, [shellTab.tabId]) + } + + const tabId = await addOpenCodeTabThroughUi(page, info.homeDir) + const [beforeRefresh] = await waitForOpenCodeSessions(page, [tabId]) + expect(beforeRefresh.sessionRef?.provider).toBe('opencode') + expect(beforeRefresh.sessionRef?.sessionId).toBeTruthy() + expect(beforeRefresh.terminalId).toBeTruthy() + await sendInputToTerminals(page, [beforeRefresh], 'ui-before-refresh') + await waitForStdinAudit(auditLogPath, [{ + tabId, + sessionId: beforeRefresh.sessionRef!.sessionId, + }], 'ui-before-refresh') + + await harness.clearSentWsMessages() + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + + await page.waitForFunction(({ expectedTabId, expectedTerminalId, expectedSessionId }) => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + const tabExists = state?.tabs?.tabs?.some((candidate: any) => candidate.id === expectedTabId) + const findTerminal = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'terminal') return node.content + if (node.type === 'split') return findTerminal(node.children?.[0]) ?? findTerminal(node.children?.[1]) + return undefined + } + const content = findTerminal(state?.panes?.layouts?.[expectedTabId]) + return tabExists + && content?.mode === 'opencode' + && content.status === 'running' + && content.terminalId === expectedTerminalId + && content.sessionRef?.provider === 'opencode' + && content.sessionRef.sessionId === expectedSessionId + }, { + expectedTabId: tabId, + expectedTerminalId: beforeRefresh.terminalId, + expectedSessionId: beforeRefresh.sessionRef!.sessionId, + }, { timeout: 30_000 }) + + const [afterRefresh] = await getPaneSnapshots(page, [tabId]) + await sendInputToTerminals(page, [afterRefresh], 'ui-after-refresh') + await waitForStdinAudit(auditLogPath, [{ + tabId, + sessionId: beforeRefresh.sessionRef!.sessionId, + }], 'ui-after-refresh') + } finally { + await server.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + + test('recovers a hidden OpenCode sessionRef when association lands while the browser is closed', async ({ page }) => { + const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-hidden-refresh-')) + const binDir = path.join(sharedRoot, 'bin') + const logsDir = path.join(sharedRoot, 'logs') + const auditLogPath = path.join(sharedRoot, 'fake-opencode-audit.jsonl') + const sharedOpencodeDataDir = path.join(sharedRoot, 'opencode-data') + const sessionEventGatePath = path.join(sharedRoot, 'release-opencode-session-events') + await installFakeOpencode(binDir) + + const server = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + fakeOpencodeSessionEventGatePath: sessionEventGatePath, + })) + + let restorePage: typeof page | undefined + try { + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + const opencodeTab = { + tabId: 'tab-hidden-opencode-refresh', + paneId: 'pane-hidden-opencode-refresh', + requestId: 'req-hidden-opencode-refresh', + mode: 'opencode' as const, + title: 'Hidden OpenCode Refresh', + } + await addTerminalTab(page, opencodeTab) + const [beforeAssociation] = await waitForRunningTerminals(page, [opencodeTab.tabId]) + expect(beforeAssociation.mode).toBe('opencode') + expect(beforeAssociation.terminalId).toBeTruthy() + expect(beforeAssociation.sessionRef).toBeUndefined() + const launch = await waitForInitialOpenCodeLaunch(auditLogPath) + const expectedSessionId = launch.rootSessionId! + + await sendInputToTerminals(page, [beforeAssociation], 'hidden-before-refresh') + await waitForStdinAudit(auditLogPath, [{ + tabId: opencodeTab.tabId, + sessionId: expectedSessionId, + }], 'hidden-before-refresh') + + const shellTab = { + tabId: 'tab-hidden-refresh-shell', + paneId: 'pane-hidden-refresh-shell', + requestId: 'req-hidden-refresh-shell', + mode: 'shell' as const, + title: 'Hidden Refresh Shell', + } + await addTerminalTab(page, shellTab) + await waitForRunningTerminals(page, [shellTab.tabId]) + + const persistedRaw = await page.evaluate(({ layoutKey }) => { + const harness = window.__FRESHELL_TEST_HARNESS__ + if (!harness) throw new Error('Freshell test harness is not installed') + harness.dispatch({ type: 'persist/flushNow' }) + const raw = window.localStorage.getItem(layoutKey) + if (!raw) throw new Error(`Missing persisted layout ${layoutKey}`) + return raw + }, { layoutKey: 'freshell.layout.v3' }) + const persistedBeforeClose = JSON.parse(persistedRaw) + expect(persistedBeforeClose.tabs?.activeTabId).toBe(shellTab.tabId) + expect(persistedBeforeClose.tabs?.tabs?.find((tab: any) => tab.id === opencodeTab.tabId)?.sessionRef).toBeUndefined() + expect(persistedBeforeClose.panes?.layouts?.[opencodeTab.tabId]?.content?.sessionRef).toBeUndefined() + + const context = page.context() + await page.close() + await fsp.writeFile(sessionEventGatePath, 'release\n', 'utf8') + await waitForSessionEventsEmitted(auditLogPath, expectedSessionId) + await expect.poll(async () => { + const response = await fetch(`${info.baseUrl}/api/terminals`, { + headers: { 'x-auth-token': info.token }, + }) + if (!response.ok) return undefined + const terminals = await response.json() as any[] + return terminals.find((terminal) => + terminal.terminalId === beforeAssociation.terminalId + )?.sessionRef + }, { timeout: 15_000 }).toEqual({ + provider: 'opencode', + sessionId: expectedSessionId, + }) + + restorePage = await context.newPage() + await restorePage.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + + const restoreHarness = new TestHarness(restorePage) + await restoreHarness.waitForHarness() + await restoreHarness.waitForConnection() + + await restorePage.waitForFunction(({ tabId, shellTabId, expectedTerminalId, expectedSessionId }) => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + const findTerminal = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'terminal') return node.content + if (node.type === 'split') return findTerminal(node.children?.[0]) ?? findTerminal(node.children?.[1]) + return undefined + } + const tab = state?.tabs?.tabs?.find((candidate: any) => candidate.id === tabId) + const content = findTerminal(state?.panes?.layouts?.[tabId]) + return state?.tabs?.activeTabId === shellTabId + && tab?.sessionRef?.provider === 'opencode' + && tab.sessionRef.sessionId === expectedSessionId + && content?.mode === 'opencode' + && content.terminalId === expectedTerminalId + && content.sessionRef?.provider === 'opencode' + && content.sessionRef.sessionId === expectedSessionId + }, { + tabId: opencodeTab.tabId, + shellTabId: shellTab.tabId, + expectedTerminalId: beforeAssociation.terminalId, + expectedSessionId, + }, { timeout: 30_000 }) + + await expect.poll(async () => { + const messages = await restoreHarness.getSentWsMessages() + return messages.some((message: any) => + message?.type === 'tabs.sync.push' + && Array.isArray(message.records) + && message.records.some((record: any) => + record.tabId === opencodeTab.tabId + && record.status === 'open' + && record.panes?.some((pane: any) => + pane.payload?.sessionRef?.provider === 'opencode' + && pane.payload.sessionRef.sessionId === expectedSessionId + ) + ) + ) + }, { timeout: TAB_REGISTRY_SYNC_INTERVAL_MS + 10_000 }).toBe(true) + + await restorePage.locator(`[data-context="tab"][data-tab-id="${opencodeTab.tabId}"]`).click() + const [afterRefresh] = await waitForOpenCodeSessions(restorePage, [opencodeTab.tabId]) + expect(afterRefresh.terminalId).toBe(beforeAssociation.terminalId) + expect(afterRefresh.sessionRef).toEqual({ + provider: 'opencode', + sessionId: expectedSessionId, + }) + await sendInputToTerminals(restorePage, [afterRefresh], 'hidden-after-refresh') + await waitForStdinAudit(auditLogPath, [{ + tabId: opencodeTab.tabId, + sessionId: expectedSessionId, + }], 'hidden-after-refresh') + } finally { + await restorePage?.close().catch(() => {}) + await server.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + + test('preserves an associated OpenCode pane across browser refresh', async ({ page }) => { + const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-refresh-')) + const binDir = path.join(sharedRoot, 'bin') + const logsDir = path.join(sharedRoot, 'logs') + const auditLogPath = path.join(sharedRoot, 'fake-opencode-audit.jsonl') + const sharedOpencodeDataDir = path.join(sharedRoot, 'opencode-data') + await installFakeOpencode(binDir) + + const server = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + })) + + try { + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + for (let index = 1; index <= 13; index += 1) { + const shellTab = { + tabId: `tab-refresh-shell-${index}`, + paneId: `pane-refresh-shell-${index}`, + requestId: `req-refresh-shell-${index}`, + mode: 'shell' as const, + title: `Refresh Shell ${index}`, + } + await addTerminalTab(page, shellTab) + await waitForRunningTerminals(page, [shellTab.tabId]) + } + + const tab = { + tabId: 'tab-opencode-refresh', + paneId: 'pane-opencode-refresh', + requestId: 'req-opencode-refresh', + mode: 'opencode' as const, + title: 'OpenCode Refresh', + } + await addTerminalTab(page, tab) + + const [beforeRefresh] = await waitForOpenCodeSessions(page, [tab.tabId]) + expect(beforeRefresh.sessionRef?.provider).toBe('opencode') + expect(beforeRefresh.sessionRef?.sessionId).toBeTruthy() + await sendInputToTerminals(page, [beforeRefresh], 'before-refresh') + await waitForStdinAudit(auditLogPath, [{ + tabId: tab.tabId, + sessionId: beforeRefresh.sessionRef!.sessionId, + }], 'before-refresh') + + await harness.clearSentWsMessages() + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + + await page.waitForFunction(({ tabId, expectedSessionId }) => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + const tabExists = state?.tabs?.tabs?.some((candidate: any) => candidate.id === tabId) + const findTerminal = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'terminal') return node.content + if (node.type === 'split') return findTerminal(node.children?.[0]) ?? findTerminal(node.children?.[1]) + return undefined + } + const content = findTerminal(state?.panes?.layouts?.[tabId]) + return tabExists + && content?.mode === 'opencode' + && content.sessionRef?.provider === 'opencode' + && content.sessionRef.sessionId === expectedSessionId + && typeof content.terminalId === 'string' + }, { + tabId: tab.tabId, + expectedSessionId: beforeRefresh.sessionRef!.sessionId, + }, { timeout: 30_000 }) + + const deadline = Date.now() + TAB_REGISTRY_SYNC_INTERVAL_MS + 10_000 + let observedPushCount = 0 + while (Date.now() < deadline) { + const snapshot = await page.evaluate(({ tabId, expectedSessionId }) => { + const harness = window.__FRESHELL_TEST_HARNESS__ + const state = harness?.getState() + const findTerminal = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'terminal') return node.content + if (node.type === 'split') return findTerminal(node.children?.[0]) ?? findTerminal(node.children?.[1]) + return undefined + } + const content = findTerminal(state?.panes?.layouts?.[tabId]) + const pushes = (harness?.getSentWsMessages?.() ?? []) + .filter((message: any) => message?.type === 'tabs.sync.push') + const badPush = pushes.find((message: any) => + !Array.isArray(message.records) + || !message.records.some((record: any) => + record.tabId === tabId + && record.status === 'open' + && record.panes?.some((pane: any) => + pane.payload?.sessionRef?.provider === 'opencode' + && pane.payload.sessionRef.sessionId === expectedSessionId + ) + ) + ) + return { + tabIds: state?.tabs?.tabs?.map((candidate: any) => candidate.id) ?? [], + hasLayout: !!state?.panes?.layouts?.[tabId], + mode: content?.mode, + terminalId: content?.terminalId, + sessionRef: content?.sessionRef, + pushCount: pushes.length, + badPush, + } + }, { + tabId: tab.tabId, + expectedSessionId: beforeRefresh.sessionRef!.sessionId, + }) + + observedPushCount = snapshot.pushCount + if ( + !snapshot.tabIds.includes(tab.tabId) + || !snapshot.hasLayout + || snapshot.mode !== 'opencode' + || snapshot.sessionRef?.provider !== 'opencode' + || snapshot.sessionRef.sessionId !== beforeRefresh.sessionRef!.sessionId + || typeof snapshot.terminalId !== 'string' + || snapshot.badPush + ) { + throw new Error(`OpenCode refresh tab/session was not stable: ${JSON.stringify(snapshot, null, 2)}`) + } + + await page.waitForTimeout(250) + } + + expect(observedPushCount).toBeGreaterThan(0) + } finally { + await server.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + test('restores surviving OpenCode panes after graceful server restart and leaves a closed pane closed', async ({ page }) => { await runRestartScenario({ page, From 8ecc7354e06e6d28730b2f6493d778436f40f75f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 21:42:28 -0700 Subject: [PATCH 3/3] chore: address OpenCode review nits --- .../plans/2026-06-01-opencode-hidden-association-restore.md | 5 ++--- test/e2e-browser/fixtures/fake-opencode.cjs | 3 +-- test/e2e-browser/specs/opencode-restart-recovery.spec.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md b/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md index 83c7c25e..96257333 100644 --- a/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md +++ b/docs/superpowers/plans/2026-06-01-opencode-hidden-association-restore.md @@ -6,7 +6,7 @@ **Current State:** The production reconciliation code from PR #380 is already merged into `origin/main`. Do not recreate it. This plan implements and lands the missing browser-level regression coverage that proves the real failure red on a pre-fix base and green on the current branch. -**Architecture:** The server remains authoritative for terminal-to-session ownership. The browser reconciles every authoritative `{ terminalId, sessionRef }` association from WebSocket events and `/api/terminals` inventory into pane layout and single-pane tab metadata, then flushes durable layout state. Hidden panes must be reconciled even when `TerminalView` is unmounted. +**Architecture:** The server remains authoritative for terminal-to-session ownership. The browser reconciles every authoritative `{ terminalId, sessionRef }` association from WebSocket events, including the `terminal.inventory` WebSocket message, into pane layout and single-pane tab metadata, then flushes durable layout state. Hidden panes must be reconciled even when `TerminalView` is unmounted. **Tech Stack:** React 18, Redux Toolkit, TypeScript, WebSocket messages, Playwright browser E2E, Node fake OpenCode fixture. @@ -124,14 +124,13 @@ File: `test/e2e-browser/fixtures/fake-opencode.cjs` ```js const sessionEventGatePath = process.env.FAKE_OPENCODE_SESSION_EVENT_GATE_PATH -const sessionEventDelayMs = Number(process.env.FAKE_OPENCODE_SESSION_EVENT_DELAY_MS || '100') ``` - [ ] Add `emitSessionEvents(res)` and `scheduleSessionEvents(res)` near the SSE event-client setup. Required behavior: -- Without `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH`, preserve existing behavior and emit `session.created` plus `session.idle` after a short delay. +- Without `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH`, preserve existing behavior and emit `session.created` plus `session.idle` after the original 100ms delay. - With `FAKE_OPENCODE_SESSION_EVENT_GATE_PATH`, poll for that file and emit events only after it exists. - If the response is destroyed before release, stop polling. - Append one audit event when the session events are emitted: diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index d2f866b2..7ea9e640 100644 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -31,7 +31,6 @@ const hostname = argValue('--hostname') || '127.0.0.1' const port = Number(argValue('--port')) const sessionArg = argValue('--session') const sessionEventGatePath = process.env.FAKE_OPENCODE_SESSION_EVENT_GATE_PATH -const sessionEventDelayMs = Number(process.env.FAKE_OPENCODE_SESSION_EVENT_DELAY_MS || '100') if (!Number.isInteger(port) || port <= 0 || port > 65535) { process.stdout.write('fake opencode: no server port requested\n') @@ -150,7 +149,7 @@ function scheduleSessionEvents(res) { return } - setTimeout(() => emitSessionEvents(res), Number.isFinite(sessionEventDelayMs) ? Math.max(0, sessionEventDelayMs) : 100) + setTimeout(() => emitSessionEvents(res), 100) } const server = http.createServer((req, res) => { diff --git a/test/e2e-browser/specs/opencode-restart-recovery.spec.ts b/test/e2e-browser/specs/opencode-restart-recovery.spec.ts index 7e9f28ee..dc14817d 100644 --- a/test/e2e-browser/specs/opencode-restart-recovery.spec.ts +++ b/test/e2e-browser/specs/opencode-restart-recovery.spec.ts @@ -524,7 +524,7 @@ async function runRestartScenario(input: { } test.describe('OpenCode restart recovery', () => { - test.setTimeout(180_000) + test.setTimeout(240_000) test('reattaches a UI-created OpenCode pane across browser refresh', async ({ page }) => { const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-ui-refresh-'))