From 5782e553d28825e3517c18aabbbcdfd17201fec4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 21:16:42 -0700 Subject: [PATCH 01/27] plan: route-safe FreshOpenCode restart recovery --- ...26-06-22-freshopencode-restart-recovery.md | 938 ++++++++++++++++++ 1 file changed, 938 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md diff --git a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md new file mode 100644 index 00000000..d46065cd --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md @@ -0,0 +1,938 @@ +# FreshOpenCode Restart Recovery 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 FreshOpenCode panes recover safely after a Freshell process restart, without sending prompts to the wrong OpenCode project, and remove the duplicate snapshot and terminal retention-loss storms covered by kata `zrrj`. + +**Architecture:** Recovery is FreshOpenCode-specific and route-safe. A durable `ses_*` id is necessary but not sufficient: recovered attach/resume must also validate the OpenCode session directory against the pane's expected cwd before any mutation is allowed. WebSocket mutations carry enough route data to recover on demand, while client snapshot refreshes become scoped and coalesced. + +**Tech Stack:** React 18, Redux Toolkit, TypeScript, Express/WebSocket `ws`, NodeNext/ESM, Vitest, Playwright browser e2e harness, fake OpenCode serve fixture. + +## Global Constraints + +- Work only in `.worktrees/zrrj-freshopencode-recovery` on branch `fix/zrrj-freshopencode-recovery`. +- Do not restart the self-hosted Freshell server without the explicit word `APPROVED`. +- Do not push behavior changes directly to `origin/main`; do not create a PR without explicit approval. +- Keep `.kata.toml` committed if it is modified; this plan does not require modifying `.kata.toml`. +- Server code uses NodeNext/ESM; relative imports must include `.js` extensions. +- Provider history reads remain read-only; never invent or mutate OpenCode history to fake recovery. +- FreshOpenCode placeholders matching `freshopencode-*` are not durable session ids and must not be recovered as durable sessions. +- Durable OpenCode session ids must match `ses_*`, but a `ses_*` alone is not a safe mutation target; recovered mutation also requires a validated cwd. +- Runtime status after restart is best-effort from OpenCode `/session/status`; do not claim an interrupted turn promise was recovered unless OpenCode reports `busy` or `retry`. +- Do not add broad OpenCode sidecar reaping in this kata. Existing owned shutdown remains, but startup must not kill unrelated `opencode serve` processes. +- Use structured logs for repo code. +- Prefer integration/e2e proof over isolated unit-only proof when behavior spans client/server boundaries. + +--- + +## Load-Bearing Results Folded Into This Plan + +- OpenCode provides the needed read-only validation surface: `GET /session/:sessionID?directory=` returns session info with a `directory` field. Freshell must compare the returned directory to the expected cwd; the query parameter alone is not enough. +- Freshell already stores the route source for materialized FreshOpenCode panes: `FreshAgentPaneContent.initialCwd` plus durable `sessionRef`. +- `FreshAgentSessionLocator` already supports `cwd`; the missing work is to carry it through WebSocket mutation messages and use it for FreshOpenCode-only recovery. +- Existing fake OpenCode serve coverage is not enough for route-safe restart proof. The fixture must add route-aware session read, prompt, message, status, and audit behavior. +- Current WebSocket attach/send can race because message handlers run concurrently and authorization is granted only after attach resolves. +- Current client `freshAgent.send.accepted` is unscoped and every `freshAgent.event` triggers a snapshot fetch. +- Terminal retention-loss coalescing is not proven by existing tests; implement it test-first and keep replay/gap invariants explicit. + +## File Structure + +- Modify `shared/ws-protocol.ts`: add `cwd` and `sessionRef` where needed on Fresh Agent messages, and add locator fields to `freshAgent.send.accepted`. +- Modify `src/components/fresh-agent/FreshAgentView.tsx`: send cwd/sessionRef on attach and mutations, resend attach on reconnect, scope/coalesce snapshot invalidations, and clear stale local echo on recovered idle snapshots. +- Modify `server/ws-handler.ts`: build route-aware locators, track pending attach per socket, wait on same-session pending attach before mutation auth, and emit scoped accepted messages. +- Modify `server/fresh-agent/runtime-manager.ts`: add FreshOpenCode-only singleflight recovery for missing `ses_*` sessions with cwd. +- Modify `server/fresh-agent/adapters/opencode/serve-manager.ts`: expose route-aware session status helper if still needed by implementation/tests. +- Modify `server/fresh-agent/adapters/opencode/adapter.ts`: validate recovered real sessions against expected cwd before registering mutable state; reconcile status after validation. +- Modify `test/e2e-browser/fixtures/fake-opencode.cjs`: make fake serve route-aware enough to prove FreshOpenCode restart recovery. +- Modify `server/terminal-stream/broker.ts`: coalesce retention-loss stream identity replacement only after tests prove one replacement preserves output/gap semantics. +- Test `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: route validation, missing/mismatched cwd fail-closed, status reconciliation. +- Test `test/unit/server/fresh-agent/runtime-manager.test.ts`: scoped lazy recovery, singleflight, provider isolation, missing cwd rejection. +- Test `test/unit/server/ws-handler-fresh-agent-ownership.test.ts`: attach/send race, failed attach does not authorize, cwd locators are passed. +- Test `test/unit/server/ws-handler-fresh-agent.test.ts`: accepted locator payload and mutation message routing. +- Test `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`: reconnect attach, cwd on mutations, accepted scoping, coalescing, stale echo clearing. +- Test `test/unit/client/store/tabRegistrySync.test.ts`: materialized FreshOpenCode tab snapshots preserve `initialCwd` and durable `sessionRef`. +- Test `test/unit/server/ws-handler-backpressure.test.ts`: one retention-loss stream change per raw output append plus replay/gap invariants. +- Add or extend `test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts`: route-safe FreshOpenCode server restart smoke with fake OpenCode. + +--- + +## Task 1: Route-Safe OpenCode Rebind + +**Files:** +- Modify: `server/fresh-agent/adapters/opencode/adapter.ts` +- Modify: `server/fresh-agent/adapters/opencode/serve-manager.ts` +- Test: `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/opencode-serve-manager.test.ts` + +**Interfaces:** +- Produces: `OpencodeServeManager.getSessionStatus(sessionId: string, route?: { cwd?: string }): Promise<{ type?: unknown } | undefined>`. +- Produces: adapter attach/resume behavior that rejects recovered `ses_*` mutation state without a validated cwd. +- Consumes: existing `OpencodeServeManager.getSession(id, route)` and `FreshAgentSessionLocator.cwd`. + +- [ ] **Step 1: Write failing tests for recovered route validation** + +Add tests to `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: + +```ts +it('validates a recovered durable session directory before making it sendable', async () => { + const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_recovered', + directory: '/repo/safe', + time: { updated: 10 }, + }) + const adapter = makeAdapter(manager) + + await expect(adapter.attach?.({ + sessionId: 'ses_recovered', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toEqual({ + sessionId: 'ses_recovered', + sessionRef: { provider: 'opencode', sessionId: 'ses_recovered' }, + }) + + await adapter.send?.('ses_recovered', { text: 'continue' }) + expect(manager.getSession).toHaveBeenCalledWith('ses_recovered', { cwd: '/repo/safe' }) + expect(manager.promptAsync).toHaveBeenCalledWith( + 'ses_recovered', + expect.objectContaining({ parts: [{ type: 'text', text: 'continue' }] }), + { cwd: '/repo/safe' }, + ) +}) + +it('rejects recovered durable sessions without cwd before registering mutable state', async () => { + const manager = makeFakeManager() + const adapter = makeAdapter(manager) + + await expect(adapter.attach?.({ + sessionId: 'ses_no_cwd', + sessionType: 'freshopencode', + provider: 'opencode', + })).rejects.toThrow(/cwd/i) + + await expect(adapter.send?.('ses_no_cwd', { text: 'must not send' })).rejects.toThrow(/not available|not tracked/i) + expect(manager.getSession).not.toHaveBeenCalled() + expect(manager.promptAsync).not.toHaveBeenCalled() +}) + +it('rejects recovered durable sessions when OpenCode reports a different directory', async () => { + const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_wrong', + directory: '/repo/other', + time: { updated: 10 }, + }) + const adapter = makeAdapter(manager) + + await expect(adapter.attach?.({ + sessionId: 'ses_wrong', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).rejects.toThrow(/directory/i) + + await expect(adapter.send?.('ses_wrong', { text: 'must not send' })).rejects.toThrow(/not available|not tracked/i) + expect(manager.promptAsync).not.toHaveBeenCalled() +}) +``` + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` +Expected: FAIL because attach currently does not validate cwd and no-cwd attach is sendable. + +- [ ] **Step 2: Implement recovered route validation** + +In `server/fresh-agent/adapters/opencode/adapter.ts`, import `realpath` from `node:fs/promises` and add a helper near `cwdRoute`: + +```ts +async function canonicalPath(value: string): Promise { + return await realpath(value) +} + +async function validateRecoveredSessionRoute(sessionId: string, cwd: string | undefined): Promise { + if (!cwd || cwd.trim().length === 0) { + throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} requires a cwd before it can be recovered for mutation.`) + } + await validateCwd(cwd) + const session = await serveManager.getSession(sessionId, { cwd }) + const reportedDirectory = typeof session?.directory === 'string' ? session.directory : undefined + if (!reportedDirectory) { + throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} did not report a directory.`) + } + const [expected, actual] = await Promise.all([canonicalPath(cwd), canonicalPath(reportedDirectory)]) + if (expected !== actual) { + throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} belongs to ${reportedDirectory}, not ${cwd}.`) + } + return actual +} +``` + +Call this helper in real-session `resume()` and `attach()` before `remember(state)` and `bindServeStream(state)`. For an existing state, validate before replacing `existing.cwd` with a new cwd; if the new cwd mismatches, keep the old state unchanged and throw. + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` +Expected: PASS for the new route validation tests. + +- [ ] **Step 3: Add attach-time status reconciliation after validation** + +Add a public helper to `server/fresh-agent/adapters/opencode/serve-manager.ts`: + +```ts +async getSessionStatus(sessionId: string, route: ServeRoute = {}): Promise<{ type?: unknown } | undefined> { + const statuses = await this.getSessionStatusMap(route) + return statuses[sessionId] +} +``` + +Add adapter tests: + +```ts +it('marks recovered durable sessions running only when OpenCode status is busy or retry', async () => { + const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ id: 'ses_busy', directory: '/repo/safe', time: { updated: 10 } }) + manager.getSessionStatus = vi.fn(async () => ({ type: 'busy' })) + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionId: 'ses_busy', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + const snapshot = await adapter.getSnapshot?.({ + threadId: 'ses_busy', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) as any + + expect(snapshot.status).toBe('running') + expect(manager.getSessionStatus).toHaveBeenCalledWith('ses_busy', { cwd: '/repo/safe' }) +}) +``` + +Implement a `reconcileStatus(state)` helper that maps `busy` and `retry` to `running`, maps `idle` to `idle`, and leaves failures/unknowns as non-running. Log a structured warning on failure; do not throw after route validation succeeds. + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts test/unit/server/fresh-agent/opencode-serve-manager.test.ts` +Expected: PASS. + +--- + +## Task 2: Scoped Runtime Recovery + +**Files:** +- Modify: `server/fresh-agent/runtime-manager.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` + +**Interfaces:** +- Produces: missing-session recovery only for `freshopencode/opencode/ses_*` with non-empty `cwd`. +- Produces: singleflight recovery keyed by existing fresh-agent session key plus cwd mismatch protection. +- Consumes: adapter `attach(locator)` route validation from Task 1. + +- [ ] **Step 1: Write failing tests for FreshOpenCode-only recovery** + +Add tests in `test/unit/server/fresh-agent/runtime-manager.test.ts`: + +```ts +it('recovers a missing FreshOpenCode durable session with cwd before mutation', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_restored' }), + send: vi.fn().mockResolvedValue(undefined), + interrupt: vi.fn().mockResolvedValue(undefined), + compact: vi.fn().mockResolvedValue(undefined), + fork: vi.fn().mockResolvedValue({ sessionId: 'ses_child' }), + } + const registry = createFreshAgentProviderRegistry([{ + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }]) + const manager = new FreshAgentRuntimeManager({ registry }) + const locator = { + sessionId: 'ses_restored', + sessionType: 'freshopencode' as const, + provider: 'opencode' as const, + cwd: '/repo/safe', + } + + await manager.send(locator, { text: 'continue' }) + await manager.interrupt(locator) + await manager.compact(locator, { instructions: 'keep decisions' }) + await manager.fork(locator) + + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(1) + expect(opencodeAdapter.attach).toHaveBeenCalledWith(locator) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_restored', { text: 'continue' }) +}) + +it('does not recover placeholders, missing cwd, or non-OpenCode providers', async () => { + const opencodeAdapter = { create: vi.fn(), attach: vi.fn(), send: vi.fn() } + const codexAdapter = { create: vi.fn(), attach: vi.fn(), send: vi.fn() } + const registry = createFreshAgentProviderRegistry([ + { sessionType: 'freshopencode', runtimeProvider: 'opencode', adapter: opencodeAdapter as any }, + { sessionType: 'freshcodex', runtimeProvider: 'codex', adapter: codexAdapter as any }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await expect(manager.send({ + sessionId: 'freshopencode-temp', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, { text: 'no' })).rejects.toThrow(/not tracked|not available/i) + await expect(manager.send({ + sessionId: 'ses_missing_cwd', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'no' })).rejects.toThrow(/not tracked|cwd|not available/i) + await expect(manager.send({ + sessionId: 'codex-thread', + sessionType: 'freshcodex', + provider: 'codex', + }, { text: 'no' })).rejects.toThrow(/not tracked/i) + + expect(opencodeAdapter.attach).not.toHaveBeenCalled() + expect(codexAdapter.attach).not.toHaveBeenCalled() +}) +``` + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/runtime-manager.test.ts` +Expected: FAIL because missing sessions are never recovered. + +- [ ] **Step 2: Implement `requireOrRecoverSession`** + +In `server/fresh-agent/runtime-manager.ts`, add: + +```ts +private readonly freshOpencodeRecoveries = new Map }>() + +private canRecoverFreshOpenCode(locator: FreshAgentSessionLocator): locator is FreshAgentSessionLocator & { cwd: string } { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + && typeof locator.cwd === 'string' + && locator.cwd.trim().length > 0 +} +``` + +Add `requireOrRecoverSession(locator)`: + +```ts +private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promise { + const existing = this.sessions.get(this.key(locator)) + if (existing) { + if (existing.sessionType !== locator.sessionType || existing.runtimeProvider !== locator.provider) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is tracked as ${existing.sessionType}/${existing.runtimeProvider}, not ${locator.sessionType}/${locator.provider}`, + ) + } + return existing + } + if (!this.canRecoverFreshOpenCode(locator)) { + return this.requireSession(locator) + } + const registration = this.requireRegistration(locator.sessionType, locator.provider) + if (!registration.adapter.attach) { + return this.requireSession(locator) + } + const key = this.key(locator) + const pending = this.freshOpencodeRecoveries.get(key) + if (pending) { + if (pending.cwd !== locator.cwd) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is already being recovered for ${pending.cwd}, not ${locator.cwd}`, + ) + } + return await pending.promise + } + const promise = Promise.resolve(registration.adapter.attach(locator)).then(() => { + const record: SessionRecord = { + sessionType: locator.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + } + this.sessions.set(key, record) + return record + }) + this.freshOpencodeRecoveries.set(key, { cwd: locator.cwd, promise }) + try { + return await promise + } finally { + if (this.freshOpencodeRecoveries.get(key)?.promise === promise) { + this.freshOpencodeRecoveries.delete(key) + } + } +} +``` + +Use this helper in `send`, `interrupt`, `compact`, `fork`, `answerQuestion`, and `resolveApproval`. Keep `kill` strict unless tests show a user-facing need; killing a lost OpenCode record is local cleanup and must not mutate provider state without route proof. + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/runtime-manager.test.ts` +Expected: PASS. + +- [ ] **Step 3: Add singleflight and cwd mismatch tests** + +Add: + +```ts +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +it('singleflights concurrent FreshOpenCode recovery for the same cwd', async () => { + const attachDeferred = createDeferred<{ sessionId: string }>() + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn(() => attachDeferred.promise), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { sessionType: 'freshopencode', runtimeProvider: 'opencode', adapter: opencodeAdapter as any }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + const locator = { sessionId: 'ses_one', sessionType: 'freshopencode' as const, provider: 'opencode' as const, cwd: '/repo' } + + const first = manager.send(locator, { text: 'one' }) + const second = manager.send(locator, { text: 'two' }) + await Promise.resolve() + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(1) + attachDeferred.resolve({ sessionId: 'ses_one' }) + await Promise.all([first, second]) + expect(opencodeAdapter.send).toHaveBeenCalledTimes(2) +}) +``` + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/runtime-manager.test.ts` +Expected: PASS. + +--- + +## Task 3: WebSocket Route, Authorization, And Race Handling + +**Files:** +- Modify: `shared/ws-protocol.ts` +- Modify: `server/ws-handler.ts` +- Test: `test/unit/server/ws-handler-fresh-agent-ownership.test.ts` +- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` + +**Interfaces:** +- Produces: all Fresh Agent mutation messages can carry `cwd?: string`; `freshAgent.attach` can carry `sessionRef`. +- Produces: same-socket mutations wait for pending attach before authorization. +- Produces: `freshAgent.send.accepted` includes `sessionId`, `sessionType`, and `provider`. + +- [ ] **Step 1: Write failing protocol/server tests** + +In `test/unit/server/ws-handler-fresh-agent-ownership.test.ts`, add: + +```ts +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +it('waits for same-socket attach before sending a raced FreshOpenCode prompt', async () => { + const attach = createDeferred<{ sessionId: string; runtimeProvider: string }>() + const runtimeManager = { + attach: vi.fn(() => attach.promise), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + sessionRef: { provider: 'opencode', sessionId: 'ses_race' }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-after-attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + text: 'continue', + })) + + await vi.waitFor(() => expect(runtimeManager.attach).toHaveBeenCalled()) + expect(runtimeManager.send).not.toHaveBeenCalled() + attach.resolve({ sessionId: 'ses_race', runtimeProvider: 'opencode' }) + + await vi.waitFor(() => { + expect(runtimeManager.send).toHaveBeenCalledWith({ + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, expect.objectContaining({ requestId: 'send-after-attach', text: 'continue' })) + }) + expect(messages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.send.accepted', + requestId: 'send-after-attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + })) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } +}) +``` + +Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: FAIL because send is unauthorized while attach is pending and accepted lacks locator fields. + +- [ ] **Step 2: Extend protocol schemas and server locators** + +In `shared/ws-protocol.ts`: +- Add `cwd: z.string().optional()` to `freshAgent.send`, `freshAgent.interrupt`, `freshAgent.compact`, `freshAgent.approval.respond`, `freshAgent.question.respond`, `freshAgent.kill`, and `freshAgent.fork`. +- Add `sessionRef: SessionLocatorSchema.optional()` to `freshAgent.attach`. +- Extend `FreshAgentServerMessage` accepted type to: + +```ts +| { + type: 'freshAgent.send.accepted' + requestId: string + sessionId: string + sessionType: string + provider: string + submittedTurnId?: string +} +``` + +In `server/ws-handler.ts`, add a helper: + +```ts +private freshAgentLocatorFromMessage(m: { + sessionId: string + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + cwd?: string + settings?: { cwd?: string } +}): FreshAgentLocator { + const cwd = typeof m.cwd === 'string' && m.cwd.trim().length > 0 + ? m.cwd + : (typeof m.settings?.cwd === 'string' && m.settings.cwd.trim().length > 0 ? m.settings.cwd : undefined) + return { + sessionId: m.sessionId, + sessionType: m.sessionType, + provider: m.provider, + ...(cwd ? { cwd } : {}), + } +} +``` + +Use it for every Fresh Agent mutation case. + +Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: tests compile, accepted locator assertions still fail until Step 3. + +- [ ] **Step 3: Implement pending attach authorization** + +Add to client state: + +```ts +pendingFreshAgentAttachByKey: Map> +``` + +In `freshAgent.attach`: +- Build locator with cwd. +- Create `attachPromise` before awaiting manager attach. +- Store it by `freshAgentKey(locator)`. +- On success, authorize and subscribe. +- On failure, send an error and do not authorize. +- In `finally`, delete only the same promise. + +Add async helper: + +```ts +private async waitForFreshAgentAuthorization( + ws: LiveWebSocket, + state: ClientState, + locator: FreshAgentLocator, + requestId?: string, +): Promise { + if (this.isFreshAgentAuthorized(state, locator)) return true + const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentKey(locator)) + if (pending) { + await pending.catch(() => undefined) + if (this.isFreshAgentAuthorized(state, locator)) return true + } + this.sendError(ws, { + code: 'UNAUTHORIZED', + message: 'Not authorized for this Fresh Agent session', + ...(requestId ? { requestId } : {}), + }) + return false +} +``` + +Use it for `send`; use it for other async mutation cases where tests are added. Keep failed attach from authorizing. + +Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: PASS. + +--- + +## Task 4: Client Route Propagation, Reconnect, And Snapshot Invalidation + +**Files:** +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` +- Modify: `src/lib/fresh-agent-ws.ts` only if needed for type narrowing. +- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Test: `test/unit/client/store/tabRegistrySync.test.ts` + +**Interfaces:** +- Produces: every Fresh Agent mutation sent by `FreshAgentView` includes cwd when known. +- Produces: materialized panes send attach on reconnect. +- Produces: snapshot refresh invalidations are scoped and coalesced. +- Produces: stale local echo is cleared when an idle recovered snapshot lacks the submitted user turn. + +- [ ] **Step 1: Write failing route propagation and reconnect tests** + +Add tests to `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`: + +```ts +it('sends cwd and sessionRef on attach and cwd on later mutations for materialized FreshOpenCode panes', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-route', + paneId: 'pane-route', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-route', + sessionId: 'ses_route', + resumeSessionId: 'ses_route', + sessionRef: { provider: 'opencode', sessionId: 'ses_route' }, + initialCwd: '/repo/safe', + status: 'idle', + }, + })) + + render() + + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.attach').at(-1)).toMatchObject({ + type: 'freshAgent.attach', + sessionId: 'ses_route', + cwd: '/repo/safe', + sessionRef: { provider: 'opencode', sessionId: 'ses_route' }, + })) + + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { target: { value: 'continue' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(sentFreshAgentMessages('freshAgent.send').at(-1)).toMatchObject({ + sessionId: 'ses_route', + cwd: '/repo/safe', + settings: expect.objectContaining({ cwd: '/repo/safe' }), + }) +}) +``` + +Add a reconnect test that invokes `ws.onReconnect` handler and expects `freshAgent.attach` for an existing `sessionId`. + +Run: `npm run test:vitest -- --run test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: FAIL because attach lacks sessionRef, control mutations lack cwd, and existing-session reconnect does not attach. + +- [ ] **Step 2: Implement route propagation** + +In `FreshAgentView.tsx`: +- Include `sessionRef: paneContent.sessionRef` in attach when present. +- Add a helper: + +```ts +function routeFields(content: FreshAgentPaneContent) { + return content.initialCwd ? { cwd: content.initialCwd } : {} +} +``` + +Use it in `freshAgent.send`, interrupt, compact, approval response, question response, fork, and kill messages. Keep `settings.cwd` for send settings too. + +Add reconnect handler for panes with `sessionId`: + +```ts +useEffect(() => { + if (!paneContent.sessionId || hidden) return + if (typeof ws.onReconnect !== 'function') return + return ws.onReconnect(() => { + const current = paneContentRef.current + if (!current.sessionId) return + sendFreshAgentMessage({ + type: 'freshAgent.attach', + sessionId: current.sessionId, + sessionType: current.sessionType, + provider: current.provider, + resumeSessionId: current.resumeSessionId, + sessionRef: current.sessionRef, + cwd: current.initialCwd, + }) + }) +}, [hidden, paneContent.sessionId, sendFreshAgentMessage, ws]) +``` + +Run: `npm run test:vitest -- --run test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: route propagation and reconnect tests PASS. + +- [ ] **Step 3: Write failing accepted scoping and coalescing tests** + +Add tests proving: +- A mounted unrelated pane ignores another pane's `freshAgent.send.accepted`. +- A burst of owner `freshAgent.send.accepted` plus `freshAgent.session.snapshot` causes one snapshot refetch. +- `freshAgent.stream` does not fetch a snapshot. +- `freshAgent.session.changed`, `freshAgent.session.snapshot`, `freshAgent.result`, `freshAgent.permission.request`, and `freshAgent.question.request` still invalidate unless the UI is changed to source those from reducer state. + +For the existing partial refresh test around `freshAgent.send.accepted` plus snapshot event, change the expected snapshot call count from two additional fetches to one additional fetch. + +Run: `npm run test:vitest -- --run test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: FAIL because the current code increments nonce immediately for every accepted/event. + +- [ ] **Step 4: Implement scoped coalesced invalidation** + +In `FreshAgentView.tsx`, replace direct `setSnapshotRefreshNonce((value) => value + 1)` calls with: + +```ts +const snapshotRefreshTimerRef = useRef | null>(null) +const scheduleSnapshotRefresh = useCallback(() => { + if (snapshotRefreshTimerRef.current) return + snapshotRefreshTimerRef.current = setTimeout(() => { + snapshotRefreshTimerRef.current = null + setSnapshotRefreshNonce((value) => value + 1) + }, 50) +}, []) +``` + +Clean it up on unmount. + +For `freshAgent.send.accepted`, require: +- `message.requestId` exists in `pendingSendMetadataRef.current`, or local echo request id matches. +- If accepted carries `sessionId/sessionType/provider`, it must match current pane. + +For `freshAgent.event`, only schedule refresh for: + +```ts +const snapshotInvalidatingEvents = new Set([ + 'freshAgent.session.changed', + 'freshAgent.session.snapshot', + 'freshAgent.result', + 'freshAgent.permission.request', + 'freshAgent.permission.cancelled', + 'freshAgent.question.request', +]) +``` + +Do not refresh on `freshAgent.stream`, `freshAgent.status`, `freshAgent.session.init`, or `freshAgent.session.metadata`. + +Run: `npm run test:vitest -- --run test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Clear stale local echo on recovered idle snapshots** + +Add a test where pane content has `pendingLocalEcho`, recovered snapshot is `idle`, and turns do not include the pending request/submitted turn. Expect the visible optimistic echo and persisted `pendingLocalEcho` to clear. + +Implement: +- After applying a snapshot, if `snapshot.status !== 'running'` and local echo has no matching turn in the snapshot, call `setLocalEchoState(null)` and update pane content with `pendingLocalEcho: undefined`. +- Preserve current behavior when a matching user turn exists. + +Run: `npm run test:vitest -- --run test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: PASS. + +- [ ] **Step 6: Prove route survives registry/persistence** + +Add or update `test/unit/client/store/tabRegistrySync.test.ts` so a materialized FreshOpenCode pane snapshot contains durable `sessionRef` and `initialCwd`. + +Run: `npm run test:vitest -- --run test/unit/client/store/tabRegistrySync.test.ts` +Expected: PASS. + +--- + +## Task 5: Route-Aware Fake OpenCode And Browser Restart Smoke + +**Files:** +- Modify: `test/e2e-browser/fixtures/fake-opencode.cjs` +- Add: `test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts` +- Reuse: `test/e2e-browser/helpers/test-server.ts` + +**Interfaces:** +- Produces: fake OpenCode serve endpoints needed to prove route-safe FreshOpenCode recovery. +- Produces: e2e smoke that starts/stops only isolated `TestServer` instances, never the self-hosted Freshell server. + +- [ ] **Step 1: Extend fake serve routes test-first through e2e expectations** + +Add helper expectations in the new e2e spec that will require fake audit entries: + +```ts +expect(auditEvents).toContainEqual(expect.objectContaining({ + event: 'serve_session_get', + sessionId, + routeDirectory: cwd, + storedDirectory: cwd, + ok: true, +})) +expect(auditEvents).toContainEqual(expect.objectContaining({ + event: 'serve_prompt_async', + sessionId, + routeDirectory: cwd, + storedDirectory: cwd, + ok: true, +})) +``` + +Run the new spec target: +`npm run test:e2e -- test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts` +Expected: FAIL because the fixture/spec do not exist or fake endpoints return 404. + +- [ ] **Step 2: Implement fake route-aware serve endpoints** + +In `test/e2e-browser/fixtures/fake-opencode.cjs`: +- Add `routeDirectory(url)` reading `url.searchParams.get('directory')`. +- Make `POST /session?directory=` create session rows using the query directory, not JSON body `directory`. +- Add `GET /session/:id?directory=` returning DB session info and auditing stored/route directory. +- Add `POST /session/:id/prompt_async?directory=` that verifies stored directory matches route, appends user/assistant messages, audits the mutation, and returns `204`. +- Add `GET /session/:id/message?directory=` and `GET /session/:id/message/:messageId?directory=` for snapshot/turn body reads. +- Add `/global/event` SSE alias for current event behavior. +- Add route-aware `/session/status?directory=` audit entries. +- Add optional env `FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE=1`; when enabled, missing or mismatched route returns 409 and audits `ok: false`. + +Run focused e2e spec again. +Expected: PASS after implementation. + +- [ ] **Step 3: Implement route-safe restart smoke** + +The new e2e should: +- Create a temp shared root, fake `opencode` binary, shared OpenCode data dir, and cwd A. +- Start `TestServer` with fake OpenCode and Fresh Agent enabled. +- Open a FreshOpenCode pane in cwd A. +- Send first prompt, wait for prompt/response and pane state `sessionId` matching `ses_*`. +- Flush persisted layout. +- Stop `server1`; start `server2` on the same port/token and shared fake OpenCode data. +- Wait for browser reconnection. +- Send a follow-up prompt. +- Assert the same `ses_*` was used, no second `session_create_requested` happened for the follow-up, route audit entries use cwd A, and the follow-up appears in the UI. + +Run: +`npm run test:e2e -- test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts` +Expected: PASS. + +--- + +## Task 6: Terminal Retention-Loss Coalescing + +**Files:** +- Modify: `server/terminal-stream/broker.ts` +- Test: `test/unit/server/ws-handler-backpressure.test.ts` + +**Interfaces:** +- Produces: one `terminal.stream.changed` with `reason: 'retention_lost'` per `terminal.output.raw` append, while preserving output retagging and replay gap semantics. + +- [ ] **Step 1: Tighten the existing failing test** + +In `test/unit/server/ws-handler-backpressure.test.ts`, update the large-fragment retention test to assert: + +```ts +expect(streamChanges).toHaveLength(1) +expect(outputs.length).toBeGreaterThan(0) +expect(outputs.every((payload) => payload.streamId === streamChanges[0].streamId)).toBe(true) +expect(outputs.map((payload) => payload.data).join('')).toHaveLength(200 * 1024) +``` + +Add a replay attach after the large append and assert either a replay gap or retained frames use the final stream id consistently. + +Run: `npm run test:vitest -- --run test/unit/server/ws-handler-backpressure.test.ts` +Expected: FAIL because current code can emit multiple stream changes inside one raw append. + +- [ ] **Step 2: Coalesce retention handling per append** + +In `server/terminal-stream/broker.ts`, change `appendOutputFrames` so fragments are appended first, and retention loss is consumed once after the loop: + +```ts +let retainedSuffixStreamId = streamId +for (const fragment of fragments) { + retainedSuffixStreamId = streamId + frames.push(state.replayRing.append(fragment, { streamId })) +} +const retainedStreamId = this.handleReplayRetentionLoss(terminalId, state, retainedSuffixStreamId) +if (retainedStreamId) { + this.retagFrames(frames, streamId, retainedStreamId) +} +``` + +If the replay test shows this loses a retained suffix boundary, adjust to track the first retained suffix stream id after loss, but keep the externally visible stream-change count at one per raw append. + +Run: `npm run test:vitest -- --run test/unit/server/ws-handler-backpressure.test.ts` +Expected: PASS. + +--- + +## Task 7: Focused And Broad Verification + +**Files:** +- No production files unless earlier tasks reveal gaps. + +**Interfaces:** +- Produces: evidence that kata `zrrj` is fixed as one cohesive change. + +- [ ] **Step 1: Run focused unit suites** + +Run: + +```bash +npm run test:vitest -- --run \ + test/unit/server/fresh-agent/opencode-serve-adapter.test.ts \ + test/unit/server/fresh-agent/opencode-serve-manager.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/ws-handler-fresh-agent-ownership.test.ts \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/server/ws-handler-backpressure.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run the FreshOpenCode browser smoke** + +Run: + +```bash +npm run test:e2e -- test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run full coordinated verification** + +Run: + +```bash +FRESHELL_TEST_SUMMARY='zrrj FreshOpenCode restart recovery' npm run check +``` + +Expected: PASS. + +- [ ] **Step 4: Commit the implementation** + +```bash +git add shared/ws-protocol.ts src/components/fresh-agent/FreshAgentView.tsx src/lib/fresh-agent-ws.ts src/store server test docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md +git commit -m "fix: recover FreshOpenCode sessions safely after restart" +``` + +Expected: commit succeeds with only kata `zrrj` files changed. From 3694be4b0ed07f7339473f8b51bf27819b232315 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 21:30:07 -0700 Subject: [PATCH 02/27] plan: validate FreshOpenCode routes at mutation time --- ...26-06-22-freshopencode-restart-recovery.md | 119 +++++++++++++----- 1 file changed, 85 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md index d46065cd..e10f2852 100644 --- a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md +++ b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md @@ -4,7 +4,7 @@ **Goal:** Make FreshOpenCode panes recover safely after a Freshell process restart, without sending prompts to the wrong OpenCode project, and remove the duplicate snapshot and terminal retention-loss storms covered by kata `zrrj`. -**Architecture:** Recovery is FreshOpenCode-specific and route-safe. A durable `ses_*` id is necessary but not sufficient: recovered attach/resume must also validate the OpenCode session directory against the pane's expected cwd before any mutation is allowed. WebSocket mutations carry enough route data to recover on demand, while client snapshot refreshes become scoped and coalesced. +**Architecture:** Recovery is FreshOpenCode-specific and route-safe. A durable `ses_*` id is necessary but not sufficient: any recovered provider mutation must validate the OpenCode session directory against the pane's expected cwd before it sends, interrupts, compacts, or forks. WebSocket mutations carry enough route data to recover on demand, while client snapshot refreshes become scoped and coalesced. **Tech Stack:** React 18, Redux Toolkit, TypeScript, Express/WebSocket `ws`, NodeNext/ESM, Vitest, Playwright browser e2e harness, fake OpenCode serve fixture. @@ -42,7 +42,7 @@ - Modify `server/ws-handler.ts`: build route-aware locators, track pending attach per socket, wait on same-session pending attach before mutation auth, and emit scoped accepted messages. - Modify `server/fresh-agent/runtime-manager.ts`: add FreshOpenCode-only singleflight recovery for missing `ses_*` sessions with cwd. - Modify `server/fresh-agent/adapters/opencode/serve-manager.ts`: expose route-aware session status helper if still needed by implementation/tests. -- Modify `server/fresh-agent/adapters/opencode/adapter.ts`: validate recovered real sessions against expected cwd before registering mutable state; reconcile status after validation. +- Modify `server/fresh-agent/adapters/opencode/adapter.ts`: keep durable attaches readable, validate recovered real sessions against expected cwd at mutation time, and reconcile status as a read-only best-effort signal. - Modify `test/e2e-browser/fixtures/fake-opencode.cjs`: make fake serve route-aware enough to prove FreshOpenCode restart recovery. - Modify `server/terminal-stream/broker.ts`: coalesce retention-loss stream identity replacement only after tests prove one replacement preserves output/gap semantics. - Test `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: route validation, missing/mismatched cwd fail-closed, status reconciliation. @@ -56,7 +56,7 @@ --- -## Task 1: Route-Safe OpenCode Rebind +## Task 1: Route-Safe OpenCode Mutation Guard **Files:** - Modify: `server/fresh-agent/adapters/opencode/adapter.ts` @@ -66,22 +66,23 @@ **Interfaces:** - Produces: `OpencodeServeManager.getSessionStatus(sessionId: string, route?: { cwd?: string }): Promise<{ type?: unknown } | undefined>`. -- Produces: adapter attach/resume behavior that rejects recovered `ses_*` mutation state without a validated cwd. +- Produces: adapter attach/resume behavior that can rebuild read-only local state for durable `ses_*` sessions without cwd. +- Produces: adapter mutation behavior that validates a recovered `ses_*` against the expected cwd before provider mutation. - Consumes: existing `OpencodeServeManager.getSession(id, route)` and `FreshAgentSessionLocator.cwd`. -- [ ] **Step 1: Write failing tests for recovered route validation** +- [ ] **Step 1: Write failing tests for mutation-boundary route validation** Add tests to `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: ```ts -it('validates a recovered durable session directory before making it sendable', async () => { +it('validates a recovered durable session directory before mutating it', async () => { const manager = makeFakeManager() manager.getSession.mockResolvedValueOnce({ id: 'ses_recovered', directory: '/repo/safe', time: { updated: 10 }, }) - const adapter = makeAdapter(manager) + const adapter = makeAdapter(manager, { canonicalizePath: async (value: string) => value } as any) await expect(adapter.attach?.({ sessionId: 'ses_recovered', @@ -102,78 +103,122 @@ it('validates a recovered durable session directory before making it sendable', ) }) -it('rejects recovered durable sessions without cwd before registering mutable state', async () => { +it('keeps no-cwd recovered durable sessions readable but not sendable', async () => { const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_no_cwd', + time: { updated: 10 }, + }) + manager.listMessages.mockResolvedValueOnce({ messages: [], nextCursor: null }) const adapter = makeAdapter(manager) await expect(adapter.attach?.({ sessionId: 'ses_no_cwd', sessionType: 'freshopencode', provider: 'opencode', - })).rejects.toThrow(/cwd/i) + })).resolves.toEqual({ + sessionId: 'ses_no_cwd', + sessionRef: { provider: 'opencode', sessionId: 'ses_no_cwd' }, + }) + await expect(adapter.getSnapshot?.({ + threadId: 'ses_no_cwd', + sessionType: 'freshopencode', + provider: 'opencode', + })).resolves.toEqual(expect.objectContaining({ threadId: 'ses_no_cwd' })) - await expect(adapter.send?.('ses_no_cwd', { text: 'must not send' })).rejects.toThrow(/not available|not tracked/i) - expect(manager.getSession).not.toHaveBeenCalled() + await expect(adapter.send?.('ses_no_cwd', { text: 'must not send' })).rejects.toThrow(/cwd/i) expect(manager.promptAsync).not.toHaveBeenCalled() }) -it('rejects recovered durable sessions when OpenCode reports a different directory', async () => { +it('rejects recovered durable session mutation when OpenCode reports a different directory', async () => { const manager = makeFakeManager() manager.getSession.mockResolvedValueOnce({ id: 'ses_wrong', directory: '/repo/other', time: { updated: 10 }, }) - const adapter = makeAdapter(manager) + const adapter = makeAdapter(manager, { canonicalizePath: async (value: string) => value } as any) await expect(adapter.attach?.({ sessionId: 'ses_wrong', sessionType: 'freshopencode', provider: 'opencode', cwd: '/repo/safe', - })).rejects.toThrow(/directory/i) + })).resolves.toEqual({ + sessionId: 'ses_wrong', + sessionRef: { provider: 'opencode', sessionId: 'ses_wrong' }, + }) - await expect(adapter.send?.('ses_wrong', { text: 'must not send' })).rejects.toThrow(/not available|not tracked/i) + await expect(adapter.send?.('ses_wrong', { text: 'must not send' })).rejects.toThrow(/belongs to|directory/i) expect(manager.promptAsync).not.toHaveBeenCalled() }) ``` Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` -Expected: FAIL because attach currently does not validate cwd and no-cwd attach is sendable. +Expected: FAIL because recovered mutation currently sends without validating the reported directory, and no-cwd recovered sessions remain sendable. - [ ] **Step 2: Implement recovered route validation** -In `server/fresh-agent/adapters/opencode/adapter.ts`, import `realpath` from `node:fs/promises` and add a helper near `cwdRoute`: +In `server/fresh-agent/adapters/opencode/adapter.ts`, import `realpath` from `node:fs/promises`, extend `CreateOpencodeFreshAgentAdapterOptions`, and add state for validation: ```ts -async function canonicalPath(value: string): Promise { - return await realpath(value) +type CreateOpencodeFreshAgentAdapterOptions = { + serveManager: OpencodeServeManager + turnTimeoutMs?: number + validateCwd?: (cwd: string) => Promise + canonicalizePath?: (cwd: string) => Promise +} + +const canonicalizePath = options.canonicalizePath ?? realpath + +type OpencodeSessionState = { + placeholderId: string + realSessionId?: string + cwd?: string + routeValidatedCwd?: string + ... } +``` -async function validateRecoveredSessionRoute(sessionId: string, cwd: string | undefined): Promise { +Add a helper near `cwdRoute`: + +```ts +async function ensureMutableRoute(state: OpencodeSessionState): Promise { + const realId = state.realSessionId + if (!realId) return + const cwd = state.cwd if (!cwd || cwd.trim().length === 0) { - throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} requires a cwd before it can be recovered for mutation.`) + throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`) } + const expected = await canonicalizePath(cwd) + if (state.routeValidatedCwd === expected) return await validateCwd(cwd) - const session = await serveManager.getSession(sessionId, { cwd }) + const session = await serveManager.getSession(realId, { cwd }) + if (typeof session?.id === 'string' && session.id !== realId) { + throw new FreshAgentLostSessionError(`OpenCode session lookup for ${realId} returned ${session.id}.`) + } const reportedDirectory = typeof session?.directory === 'string' ? session.directory : undefined if (!reportedDirectory) { - throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} did not report a directory.`) + throw new FreshAgentLostSessionError(`OpenCode session ${realId} did not report a directory.`) } - const [expected, actual] = await Promise.all([canonicalPath(cwd), canonicalPath(reportedDirectory)]) + const actual = await canonicalizePath(reportedDirectory) if (expected !== actual) { - throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} belongs to ${reportedDirectory}, not ${cwd}.`) + throw new FreshAgentLostSessionError(`OpenCode session ${realId} belongs to ${reportedDirectory}, not ${cwd}.`) } - return actual + state.routeValidatedCwd = expected } ``` -Call this helper in real-session `resume()` and `attach()` before `remember(state)` and `bindServeStream(state)`. For an existing state, validate before replacing `existing.cwd` with a new cwd; if the new cwd mismatches, keep the old state unchanged and throw. +Call `ensureMutableRoute(state)` inside `materializeOrSend` after a real id exists and before `promptAsyncForState`. Also call it in `abortForState`, `compactForState`, and `forkForState` before provider mutation. Do not call it in shared `attach()`, `resume()`, `getSnapshot()`, `getTurnPage()`, or `getTurnBody()`; read-only history viewing must still work for legacy sessions without cwd. + +When `createSession()` materializes a new session and returns a directory, set `state.routeValidatedCwd = await canonicalizePath(state.cwd)` after `state.cwd` is assigned, because this is the provider-created route. + +Update existing adapter tests that attach a real session and then mutate it to have `manager.getSession` return a matching `directory`, or pass `canonicalizePath: async (value) => value` where fake paths are used. Keep existing read-only history tests no-cwd-compatible; change only the old no-cwd-sendable assertion to expect mutation rejection. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` Expected: PASS for the new route validation tests. -- [ ] **Step 3: Add attach-time status reconciliation after validation** +- [ ] **Step 3: Add attach-time status reconciliation without requiring mutation validation** Add a public helper to `server/fresh-agent/adapters/opencode/serve-manager.ts`: @@ -189,7 +234,6 @@ Add adapter tests: ```ts it('marks recovered durable sessions running only when OpenCode status is busy or retry', async () => { const manager = makeFakeManager() - manager.getSession.mockResolvedValueOnce({ id: 'ses_busy', directory: '/repo/safe', time: { updated: 10 } }) manager.getSessionStatus = vi.fn(async () => ({ type: 'busy' })) const adapter = makeAdapter(manager) @@ -212,6 +256,7 @@ it('marks recovered durable sessions running only when OpenCode status is busy o ``` Implement a `reconcileStatus(state)` helper that maps `busy` and `retry` to `running`, maps `idle` to `idle`, and leaves failures/unknowns as non-running. Log a structured warning on failure; do not throw after route validation succeeds. +Call it after real-session `attach()` and `resume()` remember local state. This is a read-only best-effort status query; it must not make a no-cwd read-only attach fail. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts test/unit/server/fresh-agent/opencode-serve-manager.test.ts` Expected: PASS. @@ -521,6 +566,8 @@ In `shared/ws-protocol.ts`: In `server/ws-handler.ts`, add a helper: ```ts +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../shared/fresh-agent.js' + private freshAgentLocatorFromMessage(m: { sessionId: string sessionType: FreshAgentSessionType @@ -547,12 +594,18 @@ Expected: tests compile, accepted locator assertions still fail until Step 3. - [ ] **Step 3: Implement pending attach authorization** -Add to client state: +Add to the `ClientState` type and to client-state initialization: ```ts pendingFreshAgentAttachByKey: Map> ``` +Initialize it beside the existing Fresh Agent authorization/subscription maps: + +```ts +pendingFreshAgentAttachByKey: new Map(), +``` + In `freshAgent.attach`: - Build locator with cwd. - Create `attachPromise` before awaiting manager attach. @@ -864,18 +917,16 @@ Expected: FAIL because current code can emit multiple stream changes inside one In `server/terminal-stream/broker.ts`, change `appendOutputFrames` so fragments are appended first, and retention loss is consumed once after the loop: ```ts -let retainedSuffixStreamId = streamId for (const fragment of fragments) { - retainedSuffixStreamId = streamId frames.push(state.replayRing.append(fragment, { streamId })) } -const retainedStreamId = this.handleReplayRetentionLoss(terminalId, state, retainedSuffixStreamId) +const retainedStreamId = this.handleReplayRetentionLoss(terminalId, state, streamId) if (retainedStreamId) { this.retagFrames(frames, streamId, retainedStreamId) } ``` -If the replay test shows this loses a retained suffix boundary, adjust to track the first retained suffix stream id after loss, but keep the externally visible stream-change count at one per raw append. +If the replay test shows this loses a retained suffix boundary, adjust the broker to track the retained suffix boundary explicitly, but keep the externally visible stream-change count at one per raw append. Run: `npm run test:vitest -- --run test/unit/server/ws-handler-backpressure.test.ts` Expected: PASS. From 656b8fc8e14bf8815380957e870f8a42a75e2a9a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 21:56:29 -0700 Subject: [PATCH 03/27] plan: harden FreshOpenCode route recovery --- ...26-06-22-freshopencode-restart-recovery.md | 143 ++++++++++++++++-- 1 file changed, 127 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md index e10f2852..cdf7b692 100644 --- a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md +++ b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md @@ -30,17 +30,19 @@ - OpenCode provides the needed read-only validation surface: `GET /session/:sessionID?directory=` returns session info with a `directory` field. Freshell must compare the returned directory to the expected cwd; the query parameter alone is not enough. - Freshell already stores the route source for materialized FreshOpenCode panes: `FreshAgentPaneContent.initialCwd` plus durable `sessionRef`. - `FreshAgentSessionLocator` already supports `cwd`; the missing work is to carry it through WebSocket mutation messages and use it for FreshOpenCode-only recovery. +- Existing durable route data is not end-to-end today: reconnect, mutation messages, and runtime recovery must explicitly carry or enrich cwd before mutation. - Existing fake OpenCode serve coverage is not enough for route-safe restart proof. The fixture must add route-aware session read, prompt, message, status, and audit behavior. - Current WebSocket attach/send can race because message handlers run concurrently and authorization is granted only after attach resolves. -- Current client `freshAgent.send.accepted` is unscoped and every `freshAgent.event` triggers a snapshot fetch. +- Current WebSocket authorization keys ignore cwd. Pending attach and mutation authorization must use an identical route-aware locator, or explicitly reject a cwd mismatch. +- Current client `freshAgent.send.accepted` is unscoped and every `freshAgent.event` triggers a snapshot fetch; scoped accepted messages alone do not clear stale local echo on recovered idle snapshots. - Terminal retention-loss coalescing is not proven by existing tests; implement it test-first and keep replay/gap invariants explicit. ## File Structure - Modify `shared/ws-protocol.ts`: add `cwd` and `sessionRef` where needed on Fresh Agent messages, and add locator fields to `freshAgent.send.accepted`. - Modify `src/components/fresh-agent/FreshAgentView.tsx`: send cwd/sessionRef on attach and mutations, resend attach on reconnect, scope/coalesce snapshot invalidations, and clear stale local echo on recovered idle snapshots. -- Modify `server/ws-handler.ts`: build route-aware locators, track pending attach per socket, wait on same-session pending attach before mutation auth, and emit scoped accepted messages. -- Modify `server/fresh-agent/runtime-manager.ts`: add FreshOpenCode-only singleflight recovery for missing `ses_*` sessions with cwd. +- Modify `server/ws-handler.ts`: build route-aware locators, track route-aware pending attach and mutation authorization per socket, wait on identical-route pending attach before mutation auth, reject cwd mismatches, and emit scoped accepted messages. +- Modify `server/fresh-agent/runtime-manager.ts`: add FreshOpenCode-only singleflight recovery/enrichment for missing or no-route `ses_*` sessions with cwd. - Modify `server/fresh-agent/adapters/opencode/serve-manager.ts`: expose route-aware session status helper if still needed by implementation/tests. - Modify `server/fresh-agent/adapters/opencode/adapter.ts`: keep durable attaches readable, validate recovered real sessions against expected cwd at mutation time, and reconcile status as a read-only best-effort signal. - Modify `test/e2e-browser/fixtures/fake-opencode.cjs`: make fake serve route-aware enough to prove FreshOpenCode restart recovery. @@ -176,6 +178,7 @@ type OpencodeSessionState = { realSessionId?: string cwd?: string routeValidatedCwd?: string + providerCreatedInThisAdapter?: boolean ... } ``` @@ -187,6 +190,7 @@ async function ensureMutableRoute(state: OpencodeSessionState): Promise { const realId = state.realSessionId if (!realId) return const cwd = state.cwd + if (state.providerCreatedInThisAdapter && (!cwd || cwd.trim().length === 0)) return if (!cwd || cwd.trim().length === 0) { throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`) } @@ -209,11 +213,11 @@ async function ensureMutableRoute(state: OpencodeSessionState): Promise { } ``` -Call `ensureMutableRoute(state)` inside `materializeOrSend` after a real id exists and before `promptAsyncForState`. Also call it in `abortForState`, `compactForState`, and `forkForState` before provider mutation. Do not call it in shared `attach()`, `resume()`, `getSnapshot()`, `getTurnPage()`, or `getTurnBody()`; read-only history viewing must still work for legacy sessions without cwd. +Call `ensureMutableRoute(state)` inside `materializeOrSend` after a real id exists and before creating the `onceIdle` promise or calling `promptAsyncForState`. Also call it in `abortForState`, `compactForState`, and `forkForState` before provider mutation. Do not call it in shared `attach()`, `resume()`, `getSnapshot()`, `getTurnPage()`, or `getTurnBody()`; read-only history viewing must still work for legacy sessions without cwd. -When `createSession()` materializes a new session and returns a directory, set `state.routeValidatedCwd = await canonicalizePath(state.cwd)` after `state.cwd` is assigned, because this is the provider-created route. +When `createSession()` materializes a new session, set `state.providerCreatedInThisAdapter = true` after assigning the real id. This marks the session as safe for mutation in this server process only when no cwd was supplied, because Freshell just created it through this adapter. If a cwd is present later, do not use the provider-created shortcut; validate that cwd against `getSession()` before mutation. If `createSession()` also returns a directory and `state.cwd` is assigned, set `state.routeValidatedCwd = await canonicalizePath(state.cwd)`. -Update existing adapter tests that attach a real session and then mutate it to have `manager.getSession` return a matching `directory`, or pass `canonicalizePath: async (value) => value` where fake paths are used. Keep existing read-only history tests no-cwd-compatible; change only the old no-cwd-sendable assertion to expect mutation rejection. +Update existing adapter tests that attach/resume a recovered real session and then mutate it to have `manager.getSession` return a matching `directory`, or pass `canonicalizePath: async (value) => value` where fake paths are used. Keep freshly-created no-cwd sessions sendable; those tests should continue to assert no route argument is sent. Keep existing read-only history tests no-cwd-compatible; change only the old no-cwd recovered attach sendable assertion to expect mutation rejection. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` Expected: PASS for the new route validation tests. @@ -271,7 +275,8 @@ Expected: PASS. **Interfaces:** - Produces: missing-session recovery only for `freshopencode/opencode/ses_*` with non-empty `cwd`. -- Produces: singleflight recovery keyed by existing fresh-agent session key plus cwd mismatch protection. +- Produces: route enrichment for an already tracked FreshOpenCode session when a later mutation locator supplies cwd. +- Produces: singleflight recovery/enrichment keyed by existing fresh-agent session key plus cwd mismatch protection. - Consumes: adapter `attach(locator)` route validation from Task 1. - [ ] **Step 1: Write failing tests for FreshOpenCode-only recovery** @@ -340,6 +345,34 @@ it('does not recover placeholders, missing cwd, or non-OpenCode providers', asyn expect(opencodeAdapter.attach).not.toHaveBeenCalled() expect(codexAdapter.attach).not.toHaveBeenCalled() }) + +it('enriches an existing FreshOpenCode record with cwd before mutating', async () => { + const opencodeAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { sessionType: 'freshopencode', runtimeProvider: 'opencode', adapter: opencodeAdapter as any }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ sessionType: 'freshopencode', provider: 'opencode', prompt: 'start' } as any) + await manager.send({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, { text: 'continue' }) + + expect(opencodeAdapter.attach).toHaveBeenCalledWith({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_existing', { text: 'continue' }) +}) ``` Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/runtime-manager.test.ts` @@ -350,6 +383,13 @@ Expected: FAIL because missing sessions are never recovered. In `server/fresh-agent/runtime-manager.ts`, add: ```ts +type SessionRecord = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter + freshOpenCodeRouteCwd?: string +} + private readonly freshOpencodeRecoveries = new Map }>() private canRecoverFreshOpenCode(locator: FreshAgentSessionLocator): locator is FreshAgentSessionLocator & { cwd: string } { @@ -372,6 +412,17 @@ private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promis `Fresh-agent session ${locator.sessionId} is tracked as ${existing.sessionType}/${existing.runtimeProvider}, not ${locator.sessionType}/${locator.provider}`, ) } + if (this.canRecoverFreshOpenCode(locator)) { + if (existing.freshOpenCodeRouteCwd && existing.freshOpenCodeRouteCwd !== locator.cwd) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is tracked for ${existing.freshOpenCodeRouteCwd}, not ${locator.cwd}`, + ) + } + if (!existing.freshOpenCodeRouteCwd) { + if (!existing.adapter.attach) return existing + await this.singleflightFreshOpenCodeAttach(locator, existing) + } + } return existing } if (!this.canRecoverFreshOpenCode(locator)) { @@ -396,6 +447,7 @@ private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promis sessionType: locator.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, + freshOpenCodeRouteCwd: locator.cwd, } this.sessions.set(key, record) return record @@ -411,6 +463,8 @@ private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promis } ``` +Factor the shared attach/enrichment body into `singleflightFreshOpenCodeAttach(locator, existingRecord?)` rather than duplicating it for missing and existing records. On success, set `record.freshOpenCodeRouteCwd = locator.cwd`. This is what makes `settings.cwd` on `freshAgent.send` insufficient by itself: the runtime must use the cwd-bearing locator to enrich adapter state before `adapter.send()` runs. + Use this helper in `send`, `interrupt`, `compact`, `fork`, `answerQuestion`, and `resolveApproval`. Keep `kill` strict unless tests show a user-facing need; killing a lost OpenCode record is local cleanup and must not mutate provider state without route proof. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/runtime-manager.test.ts` @@ -469,7 +523,8 @@ Expected: PASS. **Interfaces:** - Produces: all Fresh Agent mutation messages can carry `cwd?: string`; `freshAgent.attach` can carry `sessionRef`. -- Produces: same-socket mutations wait for pending attach before authorization. +- Produces: same-socket mutations wait for pending attach only when session id, provider, type, and cwd match. +- Produces: cwd mismatch on a raced or already-authorized mutation fails closed instead of reusing a weaker session-id-only authorization. - Produces: `freshAgent.send.accepted` includes `sessionId`, `sessionType`, and `provider`. - [ ] **Step 1: Write failing protocol/server tests** @@ -542,6 +597,11 @@ it('waits for same-socket attach before sending a raced FreshOpenCode prompt', a }) ``` +Add a sibling test that attaches `ses_race` with `cwd: '/repo/a'`, races a `freshAgent.send` for the same `ses_race` with `cwd: '/repo/b'`, resolves the attach, and expects: +- `runtimeManager.send` is not called. +- An `UNAUTHORIZED` or locator-mismatch error is sent for the raced request. +- The authorized `/repo/a` locator remains usable. + Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: FAIL because send is unauthorized while attach is pending and accepted lacks locator fields. @@ -563,11 +623,13 @@ In `shared/ws-protocol.ts`: } ``` -In `server/ws-handler.ts`, add a helper: +In `server/ws-handler.ts`, extend the existing `FreshAgentLocator` type with `cwd?: string`, then add a helper: ```ts import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../shared/fresh-agent.js' +// Add cwd?: string to the existing FreshAgentLocator type near the top of ws-handler.ts. + private freshAgentLocatorFromMessage(m: { sessionId: string sessionType: FreshAgentSessionType @@ -588,6 +650,18 @@ private freshAgentLocatorFromMessage(m: { ``` Use it for every Fresh Agent mutation case. +When emitting `freshAgent.send.accepted`, populate the accepted locator fields from the same locator used for `manager.send`: + +```ts +this.send(ws, { + type: 'freshAgent.send.accepted', + requestId: m.requestId, + sessionId: locator.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + submittedTurnId: result?.submittedTurnId, +}) +``` Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent.test.ts` Expected: tests compile, accepted locator assertions still fail until Step 3. @@ -597,19 +671,30 @@ Expected: tests compile, accepted locator assertions still fail until Step 3. Add to the `ClientState` type and to client-state initialization: ```ts -pendingFreshAgentAttachByKey: Map> +freshAgentAuthorizations: Map +pendingFreshAgentAttachByKey: Map }> ``` Initialize it beside the existing Fresh Agent authorization/subscription maps: ```ts +freshAgentAuthorizations: new Map(), pendingFreshAgentAttachByKey: new Map(), ``` +Replace the current `Set` authorization with the map above. Keep subscriptions keyed by `freshAgentKey(locator)`, because subscriptions are a stream fanout concern. Use a route-aware key for authorization and pending attach: + +```ts +private freshAgentRouteKey(locator: FreshAgentLocator): string { + const cwd = typeof locator.cwd === 'string' && locator.cwd.trim().length > 0 ? locator.cwd : '' + return `${this.freshAgentKey(locator)}:${cwd}` +} +``` + In `freshAgent.attach`: - Build locator with cwd. - Create `attachPromise` before awaiting manager attach. -- Store it by `freshAgentKey(locator)`. +- Store it by `freshAgentRouteKey(locator)` with its cwd. - On success, authorize and subscribe. - On failure, send an error and do not authorize. - In `finally`, delete only the same promise. @@ -624,9 +709,9 @@ private async waitForFreshAgentAuthorization( requestId?: string, ): Promise { if (this.isFreshAgentAuthorized(state, locator)) return true - const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentKey(locator)) + const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentRouteKey(locator)) if (pending) { - await pending.catch(() => undefined) + await pending.promise.catch(() => undefined) if (this.isFreshAgentAuthorized(state, locator)) return true } this.sendError(ws, { @@ -638,7 +723,7 @@ private async waitForFreshAgentAuthorization( } ``` -Use it for `send`; use it for other async mutation cases where tests are added. Keep failed attach from authorizing. +`authorizeFreshAgentSession` and `isFreshAgentAuthorized` must use `freshAgentRouteKey(locator)`. This means an attach without cwd authorizes only no-cwd mutations; it does not authorize a cwd-bearing recovered mutation. Use `waitForFreshAgentAuthorization` for `send`; use it for other async mutation cases where tests are added. Keep failed attach from authorizing. Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: PASS. @@ -827,6 +912,7 @@ Expected: PASS. **Interfaces:** - Produces: fake OpenCode serve endpoints needed to prove route-safe FreshOpenCode recovery. - Produces: e2e smoke that starts/stops only isolated `TestServer` instances, never the self-hosted Freshell server. +- Produces: fake audit records for both accepted and rejected route lookups/mutations, so the test proves fail-closed behavior. - [ ] **Step 1: Extend fake serve routes test-first through e2e expectations** @@ -849,6 +935,18 @@ expect(auditEvents).toContainEqual(expect.objectContaining({ })) ``` +Also add a fake-fixture expectation or unit helper that sets `FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE=1`, calls a routed endpoint with a missing or mismatched `directory`, and requires an audit entry like: + +```ts +expect(auditEvents).toContainEqual(expect.objectContaining({ + event: 'serve_prompt_async', + sessionId, + routeDirectory: '/repo/wrong', + storedDirectory: cwd, + ok: false, +})) +``` + Run the new spec target: `npm run test:e2e -- test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts` Expected: FAIL because the fixture/spec do not exist or fake endpoints return 404. @@ -863,7 +961,7 @@ In `test/e2e-browser/fixtures/fake-opencode.cjs`: - Add `GET /session/:id/message?directory=` and `GET /session/:id/message/:messageId?directory=` for snapshot/turn body reads. - Add `/global/event` SSE alias for current event behavior. - Add route-aware `/session/status?directory=` audit entries. -- Add optional env `FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE=1`; when enabled, missing or mismatched route returns 409 and audits `ok: false`. +- Add optional env `FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE=1`; when enabled, missing or mismatched route returns 409 and audits `ok: false` for read and mutation endpoints. Run focused e2e spec again. Expected: PASS after implementation. @@ -907,7 +1005,20 @@ expect(outputs.every((payload) => payload.streamId === streamChanges[0].streamId expect(outputs.map((payload) => payload.data).join('')).toHaveLength(200 * 1024) ``` -Add a replay attach after the large append and assert either a replay gap or retained frames use the final stream id consistently. +Add a replay attach after the large append and assert concrete replay invariants: + +```ts +const replayPayloads = wsAfterLoss.send.mock.calls + .map(([raw]) => (typeof raw === 'string' ? JSON.parse(raw) : raw)) +const replayReady = replayPayloads.find((payload) => payload?.type === 'terminal.attach.ready') +const replayOutputs = replayPayloads.filter((payload) => payload?.type === 'terminal.output') +const replayGaps = replayPayloads.filter((payload) => payload?.type === 'terminal.output.gap') + +expect(replayReady?.streamId).toBe(finalStreamId) +expect(replayOutputs.every((payload) => payload.streamId === finalStreamId)).toBe(true) +expect(replayGaps.every((payload) => payload.streamId === finalStreamId)).toBe(true) +expect(replayPayloads.some((payload) => payload.streamId === ready.streamId)).toBe(false) +``` Run: `npm run test:vitest -- --run test/unit/server/ws-handler-backpressure.test.ts` Expected: FAIL because current code can emit multiple stream changes inside one raw append. From a50eab655c1c7ff3bde38251ca07a24b8298cd97 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 22:12:29 -0700 Subject: [PATCH 04/27] plan: reconcile FreshOpenCode route authorization --- ...26-06-22-freshopencode-restart-recovery.md | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md index cdf7b692..40019aed 100644 --- a/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md +++ b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md @@ -217,7 +217,7 @@ Call `ensureMutableRoute(state)` inside `materializeOrSend` after a real id exis When `createSession()` materializes a new session, set `state.providerCreatedInThisAdapter = true` after assigning the real id. This marks the session as safe for mutation in this server process only when no cwd was supplied, because Freshell just created it through this adapter. If a cwd is present later, do not use the provider-created shortcut; validate that cwd against `getSession()` before mutation. If `createSession()` also returns a directory and `state.cwd` is assigned, set `state.routeValidatedCwd = await canonicalizePath(state.cwd)`. -Update existing adapter tests that attach/resume a recovered real session and then mutate it to have `manager.getSession` return a matching `directory`, or pass `canonicalizePath: async (value) => value` where fake paths are used. Keep freshly-created no-cwd sessions sendable; those tests should continue to assert no route argument is sent. Keep existing read-only history tests no-cwd-compatible; change only the old no-cwd recovered attach sendable assertion to expect mutation rejection. +Update existing adapter tests that attach/resume a recovered real session and then mutate it to have `manager.getSession` return a matching `directory`, or pass `canonicalizePath: async (value) => value` anywhere fake absolute paths such as `/repo` are used. This includes the existing create/materialize test, because the default implementation uses `realpath()` and `/repo` does not exist in the test environment. Keep freshly-created no-cwd sessions sendable; those tests should continue to assert no route argument is sent. Keep existing read-only history tests no-cwd-compatible; change only the old no-cwd recovered attach sendable assertion to expect mutation rejection. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` Expected: PASS for the new route validation tests. @@ -259,7 +259,7 @@ it('marks recovered durable sessions running only when OpenCode status is busy o }) ``` -Implement a `reconcileStatus(state)` helper that maps `busy` and `retry` to `running`, maps `idle` to `idle`, and leaves failures/unknowns as non-running. Log a structured warning on failure; do not throw after route validation succeeds. +Implement a `reconcileStatus(state)` helper that first checks whether `serveManager.getSessionStatus` is a function, maps `busy` and `retry` to `running`, maps `idle` to `idle`, and leaves failures/unknowns as non-running. Wrap the call so missing fake methods, transport errors, and malformed responses are logged as structured warnings and never thrown after route validation succeeds. Call it after real-session `attach()` and `resume()` remember local state. This is a read-only best-effort status query; it must not make a no-cwd read-only attach fail. Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts test/unit/server/fresh-agent/opencode-serve-manager.test.ts` @@ -524,7 +524,7 @@ Expected: PASS. **Interfaces:** - Produces: all Fresh Agent mutation messages can carry `cwd?: string`; `freshAgent.attach` can carry `sessionRef`. - Produces: same-socket mutations wait for pending attach only when session id, provider, type, and cwd match. -- Produces: cwd mismatch on a raced or already-authorized mutation fails closed instead of reusing a weaker session-id-only authorization. +- Produces: cwd mismatch on a raced or already-authorized durable FreshOpenCode `ses_*` mutation fails closed instead of reusing a weaker session-id-only authorization. - Produces: `freshAgent.send.accepted` includes `sessionId`, `sessionType`, and `provider`. - [ ] **Step 1: Write failing protocol/server tests** @@ -602,6 +602,12 @@ Add a sibling test that attaches `ses_race` with `cwd: '/repo/a'`, races a `fres - An `UNAUTHORIZED` or locator-mismatch error is sent for the raced request. - The authorized `/repo/a` locator remains usable. +Update the existing `ws-handler-fresh-agent.test.ts` create/send ownership test so: +- `freshAgent.send` expects a cwd-bearing locator when the message has `settings.cwd`. +- interrupt, approval, question, fork, and kill assertions keep no-cwd locators unless their test messages include `cwd`. +- `freshAgent.send.accepted` expectation includes `sessionId`, `sessionType`, and `provider`. +- A session created without cwd for a non-FreshOpenCode provider can still send with `settings.cwd`; route-aware authorization is FreshOpenCode durable-session protection, not a global ban on cwd-bearing sends. + Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: FAIL because send is unauthorized while attach is pending and accepted lacks locator fields. @@ -682,19 +688,30 @@ freshAgentAuthorizations: new Map(), pendingFreshAgentAttachByKey: new Map(), ``` -Replace the current `Set` authorization with the map above. Keep subscriptions keyed by `freshAgentKey(locator)`, because subscriptions are a stream fanout concern. Use a route-aware key for authorization and pending attach: +Replace the current `Set` authorization with the map above. Keep subscriptions keyed by `freshAgentKey(locator)`, because subscriptions are a stream fanout concern. Use a route-aware key only for durable FreshOpenCode mutations where a cwd is present; otherwise keep the current session-id authorization key so normal create-to-send flows for Codex/Claude/placeholders stay authorized: ```ts -private freshAgentRouteKey(locator: FreshAgentLocator): string { +private requiresFreshOpenCodeRouteAuthorization(locator: FreshAgentLocator): boolean { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + && typeof locator.cwd === 'string' + && locator.cwd.trim().length > 0 +} + +private freshAgentAuthorizationKey(locator: FreshAgentLocator): string { + if (!this.requiresFreshOpenCodeRouteAuthorization(locator)) return this.freshAgentKey(locator) const cwd = typeof locator.cwd === 'string' && locator.cwd.trim().length > 0 ? locator.cwd : '' return `${this.freshAgentKey(locator)}:${cwd}` } ``` +When `freshAgent.create` succeeds, authorize the created record with `cwd: m.cwd` when present and keep that cwd in any cached created record used for duplicate request replay. When a placeholder send materializes a durable `ses_*`, authorize and subscribe to the materialized locator with the same cwd as the send locator when one exists. This keeps a newly-created FreshOpenCode pane's first follow-up send authorized after materialization. + In `freshAgent.attach`: - Build locator with cwd. - Create `attachPromise` before awaiting manager attach. -- Store it by `freshAgentRouteKey(locator)` with its cwd. +- Store it by `freshAgentAuthorizationKey(locator)` with its cwd. - On success, authorize and subscribe. - On failure, send an error and do not authorize. - In `finally`, delete only the same promise. @@ -709,7 +726,7 @@ private async waitForFreshAgentAuthorization( requestId?: string, ): Promise { if (this.isFreshAgentAuthorized(state, locator)) return true - const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentRouteKey(locator)) + const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentAuthorizationKey(locator)) if (pending) { await pending.promise.catch(() => undefined) if (this.isFreshAgentAuthorized(state, locator)) return true @@ -723,7 +740,7 @@ private async waitForFreshAgentAuthorization( } ``` -`authorizeFreshAgentSession` and `isFreshAgentAuthorized` must use `freshAgentRouteKey(locator)`. This means an attach without cwd authorizes only no-cwd mutations; it does not authorize a cwd-bearing recovered mutation. Use `waitForFreshAgentAuthorization` for `send`; use it for other async mutation cases where tests are added. Keep failed attach from authorizing. +`authorizeFreshAgentSession` and `isFreshAgentAuthorized` must use `freshAgentAuthorizationKey(locator)`. This means a durable FreshOpenCode `ses_*` attach without cwd authorizes only no-cwd durable mutations; it does not authorize a cwd-bearing recovered mutation. Non-FreshOpenCode providers and FreshOpenCode placeholders continue to use the existing session-id key. Use `waitForFreshAgentAuthorization` for `send`; use it for other async mutation cases where tests are added. Keep failed attach from authorizing. Run: `npm run test:vitest -- --run test/unit/server/ws-handler-fresh-agent-ownership.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: PASS. @@ -1021,7 +1038,7 @@ expect(replayPayloads.some((payload) => payload.streamId === ready.streamId)).to ``` Run: `npm run test:vitest -- --run test/unit/server/ws-handler-backpressure.test.ts` -Expected: FAIL because current code can emit multiple stream changes inside one raw append. +Expected: FAIL because current code can emit multiple stream changes inside one raw append. If the existing 200KB fixture only crosses the retention boundary once and does not fail RED, increase the single raw append size or reduce the test broker retention limit until the pre-fix code produces more than one `terminal.stream.changed` for that one append; keep the assertion on externally observed WebSocket messages, not broker internals. - [ ] **Step 2: Coalesce retention handling per append** @@ -1093,7 +1110,26 @@ Expected: PASS. - [ ] **Step 4: Commit the implementation** ```bash -git add shared/ws-protocol.ts src/components/fresh-agent/FreshAgentView.tsx src/lib/fresh-agent-ws.ts src/store server test docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md +git add \ + shared/ws-protocol.ts \ + server/ws-handler.ts \ + server/fresh-agent/runtime-manager.ts \ + server/fresh-agent/adapters/opencode/adapter.ts \ + server/fresh-agent/adapters/opencode/serve-manager.ts \ + server/terminal-stream/broker.ts \ + src/components/fresh-agent/FreshAgentView.tsx \ + src/lib/fresh-agent-ws.ts \ + test/unit/server/fresh-agent/opencode-serve-adapter.test.ts \ + test/unit/server/fresh-agent/opencode-serve-manager.test.ts \ + test/unit/server/fresh-agent/runtime-manager.test.ts \ + test/unit/server/ws-handler-fresh-agent.test.ts \ + test/unit/server/ws-handler-fresh-agent-ownership.test.ts \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/client/store/tabRegistrySync.test.ts \ + test/unit/server/ws-handler-backpressure.test.ts \ + test/e2e-browser/fixtures/fake-opencode.cjs \ + test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts \ + docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md git commit -m "fix: recover FreshOpenCode sessions safely after restart" ``` From 7818072844ee82aaa3469a9def6536c6a34aa47d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 22:19:18 -0700 Subject: [PATCH 05/27] Guard recovered opencode mutations by route --- .../fresh-agent/adapters/opencode/adapter.ts | 72 +++++++++++- .../adapters/opencode/serve-manager.ts | 5 + .../opencode-serve-adapter.test.ts | 107 ++++++++++++++++-- .../opencode-serve-manager.test.ts | 20 ++++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index ab07fe56..1f2d9901 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import path from 'node:path' import type { FreshAgentCreateRequest, @@ -37,6 +37,8 @@ type OpencodeSessionState = { placeholderId: string realSessionId?: string cwd?: string + routeValidatedCwd?: string + providerCreatedInThisAdapter?: boolean model?: string effort?: string status: string @@ -53,6 +55,7 @@ type CreateOpencodeFreshAgentAdapterOptions = { dataHome?: string turnTimeoutMs?: number validateCwd?: (cwd: string) => Promise + canonicalizePath?: (cwd: string) => Promise } function makePlaceholderId(requestId: string): string { @@ -77,6 +80,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const serveManager = options.serveManager const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS const validateCwd = options.validateCwd ?? defaultValidateCwd + const canonicalizePath = options.canonicalizePath ?? realpath const dbPath = options.dbPath ?? path.join(options.dataHome ?? defaultOpencodeDataHome(), 'opencode.db') // Lazily create the legacy reader only if a legacy placeholder resume is attempted. let historyReader: OpencodeHistoryReader | undefined = options.historyReader @@ -104,6 +108,58 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen return typeof cwd === 'string' && cwd.trim().length > 0 ? { cwd } : undefined } + async function ensureMutableRoute(state: OpencodeSessionState): Promise { + const realId = state.realSessionId + if (!realId) return + const cwd = state.cwd + if (state.providerCreatedInThisAdapter && (!cwd || cwd.trim().length === 0)) return + if (!cwd || cwd.trim().length === 0) { + throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`) + } + const expected = await canonicalizePath(cwd) + if (state.routeValidatedCwd === expected) return + await validateCwd(cwd) + const session = await serveManager.getSession(realId, { cwd }) + if (typeof session?.id === 'string' && session.id !== realId) { + throw new FreshAgentLostSessionError(`OpenCode session lookup for ${realId} returned ${session.id}.`) + } + const reportedDirectory = typeof session?.directory === 'string' ? session.directory : undefined + if (!reportedDirectory) { + throw new FreshAgentLostSessionError(`OpenCode session ${realId} did not report a directory.`) + } + const actual = await canonicalizePath(reportedDirectory) + if (expected !== actual) { + throw new FreshAgentLostSessionError(`OpenCode session ${realId} belongs to ${reportedDirectory}, not ${cwd}.`) + } + state.routeValidatedCwd = expected + } + + async function reconcileStatus(state: OpencodeSessionState): Promise { + const realId = state.realSessionId + if (!realId) return + const getSessionStatus = (serveManager as { getSessionStatus?: (sessionId: string, route?: { cwd?: string }) => Promise<{ type?: unknown } | undefined> }).getSessionStatus + if (typeof getSessionStatus !== 'function') return + try { + const status = await getSessionStatus.call(serveManager, realId, cwdRoute(state.cwd) ?? {}) + if (!status || typeof status !== 'object' || Array.isArray(status)) return + const type = status.type + if (type === 'busy' || type === 'retry') { + state.status = 'running' + return + } + if (type === 'idle') { + state.status = 'idle' + } + } catch (err) { + log.warn({ + err, + provider: 'opencode', + sessionIdHash: hashForLogs(realId), + ...(state.cwd ? { cwdHash: hashForLogs(state.cwd) } : {}), + }, 'opencode status reconciliation failed') + } + } + async function promptAsyncForState( state: OpencodeSessionState, realId: string, @@ -119,6 +175,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async function abortForState(state: OpencodeSessionState): Promise { if (!state.realSessionId) return + await ensureMutableRoute(state) const route = cwdRoute(state.cwd) if (route) { await serveManager.abort(state.realSessionId, route) @@ -129,6 +186,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async function compactForState(state: OpencodeSessionState, input?: { instructions?: string }): Promise { if (!state.realSessionId) return + await ensureMutableRoute(state) const route = cwdRoute(state.cwd) if (route) { await serveManager.compact(state.realSessionId, input, route) @@ -145,6 +203,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen if (!state.realSessionId) { throw new FreshAgentLostSessionError(`OpenCode session ${state.placeholderId} has not materialized; cannot fork.`) } + await ensureMutableRoute(state) const route = cwdRoute(state.cwd) return route ? await serveManager.fork(state.realSessionId, route) @@ -214,14 +273,19 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen if (effectiveCwd) await validateCwd(effectiveCwd) const session = await serveManager.createSession({ title: undefined, ...(effectiveCwd ? { directory: effectiveCwd } : {}) }) state.realSessionId = session.id + state.providerCreatedInThisAdapter = true if (typeof session.directory === 'string' && session.directory.length > 0) state.cwd = session.directory else if (effectiveCwd) state.cwd = effectiveCwd + if (typeof session.directory === 'string' && session.directory.length > 0 && state.cwd) { + state.routeValidatedCwd = await canonicalizePath(state.cwd) + } remember(state) bindServeStream(state) emitMaterialized(state) } const realId = state.realSessionId! + await ensureMutableRoute(state) const idleRoute = cwdRoute(state.cwd) const idle = idleRoute ? serveManager.onceIdle(realId, turnTimeoutMs, idleRoute) @@ -307,6 +371,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen } remember(state) bindServeStream(state) + await reconcileStatus(state) return { sessionId: real, sessionRef: { provider: 'opencode', sessionId: real } } } if (!isRealOpencodeSessionId(sessionId)) { @@ -318,6 +383,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen } remember(state) bindServeStream(state) + await reconcileStatus(state) return { sessionId, sessionRef: { provider: 'opencode', sessionId } } }, @@ -325,9 +391,11 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const existing = sessions.get(locator.sessionId) if (existing) { if (locator.cwd) { + if (existing.cwd !== locator.cwd) existing.routeValidatedCwd = undefined existing.cwd = locator.cwd } remember(existing) + await reconcileStatus(existing) return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } } } if (isPlaceholderOpencodeSessionId(locator.sessionId) || !isRealOpencodeSessionId(locator.sessionId)) { @@ -342,6 +410,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen } remember(state) bindServeStream(state) + await reconcileStatus(state) return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } } }, @@ -388,6 +457,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen placeholderId: child.id, realSessionId: child.id, cwd: child.directory ?? state.cwd, + providerCreatedInThisAdapter: true, model: state.model, effort: state.effort, status: 'idle', diff --git a/server/fresh-agent/adapters/opencode/serve-manager.ts b/server/fresh-agent/adapters/opencode/serve-manager.ts index 00348f9c..a2fdfada 100644 --- a/server/fresh-agent/adapters/opencode/serve-manager.ts +++ b/server/fresh-agent/adapters/opencode/serve-manager.ts @@ -329,6 +329,11 @@ export class OpencodeServeManager { return this.json(withRoute('/session/status', route), { method: 'GET', ...init }) } + async getSessionStatus(sessionId: string, route: ServeRoute = {}): Promise<{ type?: unknown } | undefined> { + const statuses = await this.getSessionStatusMap(route) + return statuses[sessionId] + } + async createSession(input: { title?: string; parentID?: string; directory?: string } = {}): Promise<{ id: string; directory?: string; title?: string }> { const body: { title?: string; parentID?: string } = {} if (input.title !== undefined) body.title = input.title diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index f5eff62d..c8e1c1b7 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -40,7 +40,13 @@ function makeFakeManager() { promptAsync: vi.fn(async () => undefined), listMessages: vi.fn(async () => ({ messages: [], nextCursor: null })), getMessage: vi.fn(async () => null), - getSession: vi.fn(async () => ({ id: 'ses_real_1', title: 'T', time: { updated: 5 } })), + getSession: vi.fn(async (id: string, route?: { cwd?: string }) => ({ + id, + ...(route?.cwd ? { directory: route.cwd } : {}), + title: 'T', + time: { updated: 5 }, + })), + getSessionStatus: vi.fn(async () => undefined), abort: vi.fn(async () => undefined), compact: vi.fn(async () => undefined), fork: vi.fn(async (): Promise<{ id: string; directory?: string }> => ({ id: 'ses_child_1' })), @@ -60,6 +66,7 @@ function makeAdapter(manager: FakeManager, overrides: Partial undefined, + canonicalizePath: async (value: string) => value, ...overrides, }) } @@ -222,6 +229,7 @@ describe('OpenCode serve adapter: create + send', () => { }) await adapter.send?.('ses_attached_send', { text: 'continue' }) + expect(manager.getSession).toHaveBeenCalledWith('ses_attached_send', { cwd: '/repo/restored-worktree' }) expect(manager.promptAsync).toHaveBeenCalledWith( 'ses_attached_send', { parts: [{ type: 'text', text: 'continue' }] }, @@ -229,23 +237,107 @@ describe('OpenCode serve adapter: create + send', () => { ) }) - it('keeps attached no-cwd sessions sendable without a route argument', async () => { + it('keeps no-cwd recovered durable sessions readable but not sendable', async () => { const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_no_cwd', + time: { updated: 10 }, + }) + manager.listMessages.mockResolvedValueOnce({ messages: [], nextCursor: null }) const adapter = makeAdapter(manager) - await adapter.attach?.({ + await expect(adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', - sessionId: 'ses_attached_nocwd', + sessionId: 'ses_no_cwd', + })).resolves.toEqual({ + sessionId: 'ses_no_cwd', + sessionRef: { provider: 'opencode', sessionId: 'ses_no_cwd' }, }) - await adapter.send?.('ses_attached_nocwd', { text: 'continue' }) + await expect(adapter.getSnapshot?.({ + threadId: 'ses_no_cwd', + sessionType: 'freshopencode', + provider: 'opencode', + })).resolves.toEqual(expect.objectContaining({ threadId: 'ses_no_cwd' })) + + await expect(adapter.send?.('ses_no_cwd', { text: 'must not send' })).rejects.toThrow(/cwd/i) + expect(manager.promptAsync).not.toHaveBeenCalled() + }) + + it('validates a recovered durable session directory before mutating it', async () => { + const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_recovered', + directory: '/repo/safe', + time: { updated: 10 }, + }) + const adapter = makeAdapter(manager, { canonicalizePath: async (value: string) => value } as any) + + await expect(adapter.attach?.({ + sessionId: 'ses_recovered', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toEqual({ + sessionId: 'ses_recovered', + sessionRef: { provider: 'opencode', sessionId: 'ses_recovered' }, + }) + + await adapter.send?.('ses_recovered', { text: 'continue' }) + expect(manager.getSession).toHaveBeenCalledWith('ses_recovered', { cwd: '/repo/safe' }) expect(manager.promptAsync).toHaveBeenCalledWith( - 'ses_attached_nocwd', - { parts: [{ type: 'text', text: 'continue' }] }, + 'ses_recovered', + expect.objectContaining({ parts: [{ type: 'text', text: 'continue' }] }), + { cwd: '/repo/safe' }, ) }) + it('rejects recovered durable session mutation when OpenCode reports a different directory', async () => { + const manager = makeFakeManager() + manager.getSession.mockResolvedValueOnce({ + id: 'ses_wrong', + directory: '/repo/other', + time: { updated: 10 }, + }) + const adapter = makeAdapter(manager, { canonicalizePath: async (value: string) => value } as any) + + await expect(adapter.attach?.({ + sessionId: 'ses_wrong', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toEqual({ + sessionId: 'ses_wrong', + sessionRef: { provider: 'opencode', sessionId: 'ses_wrong' }, + }) + + await expect(adapter.send?.('ses_wrong', { text: 'must not send' })).rejects.toThrow(/belongs to|directory/i) + expect(manager.promptAsync).not.toHaveBeenCalled() + }) + + it('marks recovered durable sessions running only when OpenCode status is busy or retry', async () => { + const manager = makeFakeManager() + manager.getSessionStatus = vi.fn(async () => ({ type: 'busy' })) + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionId: 'ses_busy', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + const snapshot = await adapter.getSnapshot?.({ + threadId: 'ses_busy', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) as any + + expect(snapshot.status).toBe('running') + expect(manager.getSessionStatus).toHaveBeenCalledWith('ses_busy', { cwd: '/repo/safe' }) + }) + it('recovers from a failed send and still processes later sends', async () => { const manager = makeFakeManager() const adapter = makeAdapter(manager) @@ -483,6 +575,7 @@ describe('OpenCode serve adapter: control', () => { }) await adapter.send?.('ses_child_1', { text: 'child continue' }) + expect(manager.getSession).toHaveBeenCalledWith('ses_known_cwd', { cwd: '/repo/control' }) expect(manager.abort).toHaveBeenCalledWith('ses_known_cwd', { cwd: '/repo/control' }) expect(manager.compact).toHaveBeenCalledWith('ses_known_cwd', { instructions: 'trim' }, { cwd: '/repo/control' }) expect(manager.fork).toHaveBeenCalledWith('ses_known_cwd', { cwd: '/repo/control' }) diff --git a/test/unit/server/fresh-agent/opencode-serve-manager.test.ts b/test/unit/server/fresh-agent/opencode-serve-manager.test.ts index d5d86961..2122693c 100644 --- a/test/unit/server/fresh-agent/opencode-serve-manager.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-manager.test.ts @@ -200,6 +200,26 @@ describe('OpencodeServeManager lifecycle', () => { }) describe('OpencodeServeManager HTTP client', () => { + it('returns one session status entry from the routed status map', async () => { + const fetchFn = vi.fn(async (url: string) => { + if (url.endsWith('/global/health')) return jsonResponse({ healthy: true }) + if (url === 'http://127.0.0.1:47999/session/status?directory=%2Frepo%2Fsafe') { + return jsonResponse({ + ses_safe: { type: 'busy' }, + ses_other: { type: 'idle' }, + }) + } + return jsonResponse({}) + }) + const { manager } = makeManager({ fetchFn: fetchFn as any }) + + await expect(manager.getSessionStatus('ses_safe', { cwd: '/repo/safe' })).resolves.toEqual({ type: 'busy' }) + expect(fetchFn).toHaveBeenCalledWith( + 'http://127.0.0.1:47999/session/status?directory=%2Frepo%2Fsafe', + expect.anything(), + ) + }) + it('creates a session and posts a prompt_async with model object + variant', async () => { const calls: Array<{ url: string; init: any }> = [] const fetchFn = vi.fn(async (url: string, init: any) => { From c4d45b8102df344393cef49e7f5ae52a3c4578c5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 22:26:31 -0700 Subject: [PATCH 06/27] Fix opencode recovered status reconciliation --- .../fresh-agent/adapters/opencode/adapter.ts | 32 +++-- .../opencode-serve-adapter.test.ts | 128 ++++++++++++++++++ 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index 1f2d9901..76b45268 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -137,25 +137,41 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async function reconcileStatus(state: OpencodeSessionState): Promise { const realId = state.realSessionId if (!realId) return + state.status = 'idle' const getSessionStatus = (serveManager as { getSessionStatus?: (sessionId: string, route?: { cwd?: string }) => Promise<{ type?: unknown } | undefined> }).getSessionStatus - if (typeof getSessionStatus !== 'function') return + const logContext = { + provider: 'opencode', + sessionIdHash: hashForLogs(realId), + ...(state.cwd ? { cwdHash: hashForLogs(state.cwd) } : {}), + } + if (typeof getSessionStatus !== 'function') { + log.warn({ + ...logContext, + reason: 'missing_get_session_status', + }, 'opencode status reconciliation skipped') + return + } try { const status = await getSessionStatus.call(serveManager, realId, cwdRoute(state.cwd) ?? {}) - if (!status || typeof status !== 'object' || Array.isArray(status)) return + if (!status || typeof status !== 'object' || Array.isArray(status) || typeof status.type !== 'string') { + log.warn({ + ...logContext, + reason: 'malformed_session_status', + status, + }, 'opencode status reconciliation received malformed status') + return + } const type = status.type if (type === 'busy' || type === 'retry') { state.status = 'running' return } - if (type === 'idle') { - state.status = 'idle' - } + if (type === 'idle') return } catch (err) { log.warn({ + ...logContext, err, - provider: 'opencode', - sessionIdHash: hashForLogs(realId), - ...(state.cwd ? { cwdHash: hashForLogs(state.cwd) } : {}), + reason: 'get_session_status_failed', }, 'opencode status reconciliation failed') } } diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index c8e1c1b7..292c30ab 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -5,11 +5,25 @@ const observabilityMocks = vi.hoisted(() => ({ recordFreshAgentObservabilityEvent: vi.fn(), })) +const loggerMocks = vi.hoisted(() => { + const logger = { + child: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } + logger.child.mockReturnValue(logger) + return { logger } +}) + vi.mock('../../../../server/fresh-agent/observability.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, recordFreshAgentObservabilityEvent: observabilityMocks.recordFreshAgentObservabilityEvent } }) +vi.mock('../../../../server/logger.js', () => ({ logger: loggerMocks.logger })) + import { createOpencodeFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/opencode/adapter.js' type FakeManager = ReturnType @@ -338,6 +352,120 @@ describe('OpenCode serve adapter: create + send', () => { expect(manager.getSessionStatus).toHaveBeenCalledWith('ses_busy', { cwd: '/repo/safe' }) }) + it('resets an existing attached session back to idle when status reconciliation is malformed', async () => { + loggerMocks.logger.warn.mockClear() + const manager = makeFakeManager() + manager.getSessionStatus = vi.fn() + .mockResolvedValueOnce({ type: 'busy' }) + .mockResolvedValueOnce({ nope: 'bad' }) + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionId: 'ses_cached', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + await expect(adapter.getSnapshot?.({ + threadId: 'ses_cached', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toMatchObject({ status: 'running' }) + + await adapter.attach?.({ + sessionId: 'ses_cached', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + + await expect(adapter.getSnapshot?.({ + threadId: 'ses_cached', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toMatchObject({ status: 'idle' }) + expect(loggerMocks.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'malformed_session_status' }), + 'opencode status reconciliation received malformed status', + ) + }) + + it('keeps recovered sessions idle and warns when getSessionStatus is missing', async () => { + loggerMocks.logger.warn.mockClear() + const manager = makeFakeManager() + delete (manager as Partial).getSessionStatus + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionId: 'ses_no_helper', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + + await expect(adapter.getSnapshot?.({ + threadId: 'ses_no_helper', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toMatchObject({ status: 'idle' }) + expect(loggerMocks.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'missing_get_session_status' }), + 'opencode status reconciliation skipped', + ) + }) + + it('keeps resumed sessions idle when getSessionStatus throws and still does not fail resume', async () => { + loggerMocks.logger.warn.mockClear() + const manager = makeFakeManager() + manager.getSessionStatus = vi.fn(async () => { throw new Error('status failed') }) + const adapter = makeAdapter(manager) + + await expect(adapter.resume?.({ + resumeSessionId: 'ses_resume_throw', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toEqual({ + sessionId: 'ses_resume_throw', + sessionRef: { provider: 'opencode', sessionId: 'ses_resume_throw' }, + }) + + await expect(adapter.getSnapshot?.({ + threadId: 'ses_resume_throw', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toMatchObject({ status: 'idle' }) + expect(loggerMocks.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'get_session_status_failed' }), + 'opencode status reconciliation failed', + ) + }) + + it('marks resumed durable sessions running when OpenCode reports retry', async () => { + const manager = makeFakeManager() + manager.getSessionStatus = vi.fn(async () => ({ type: 'retry' })) + const adapter = makeAdapter(manager) + + await adapter.resume?.({ + resumeSessionId: 'ses_resume_retry', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + + await expect(adapter.getSnapshot?.({ + threadId: 'ses_resume_retry', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toMatchObject({ status: 'running' }) + expect(manager.getSessionStatus).toHaveBeenCalledWith('ses_resume_retry', { cwd: '/repo/safe' }) + }) + it('recovers from a failed send and still processes later sends', async () => { const manager = makeFakeManager() const adapter = makeAdapter(manager) From 002d87cbac086cbfb058355459e202270c00d620 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 22:33:06 -0700 Subject: [PATCH 07/27] Add scoped FreshOpenCode runtime recovery --- server/fresh-agent/runtime-manager.ts | 101 +++++++- .../fresh-agent/runtime-manager.test.ts | 232 ++++++++++++++++++ 2 files changed, 327 insertions(+), 6 deletions(-) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index d8684ac1..8ac363d1 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -90,10 +90,12 @@ type SessionRecord = { sessionType: FreshAgentSessionType runtimeProvider: FreshAgentRuntimeProvider adapter: FreshAgentRuntimeAdapter + freshOpenCodeRouteCwd?: string } export class FreshAgentRuntimeManager { private readonly sessions = new Map() + private readonly freshOpencodeRecoveries = new Map }>() constructor(private readonly options: FreshAgentRuntimeManagerOptions) {} @@ -134,6 +136,7 @@ export class FreshAgentRuntimeManager { sessionType: input.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, + freshOpenCodeRouteCwd: this.canRecoverFreshOpenCode({ ...input, sessionId }) ? input.cwd : undefined, }) return { @@ -179,7 +182,7 @@ export class FreshAgentRuntimeManager { locator: FreshAgentSessionLocator, input: { requestId?: string; text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }, ): Promise { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.send) { throw new FreshAgentUnsupportedCapabilityError(`Send is not supported for ${record.sessionType}`) } @@ -211,7 +214,7 @@ export class FreshAgentRuntimeManager { } async interrupt(locator: FreshAgentSessionLocator) { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.interrupt) { throw new FreshAgentUnsupportedCapabilityError(`Interrupt is not supported for ${record.sessionType}`) } @@ -219,7 +222,7 @@ export class FreshAgentRuntimeManager { } async compact(locator: FreshAgentSessionLocator, input?: { instructions?: string }) { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.compact) { throw new FreshAgentUnsupportedCapabilityError(`Compact is not supported for ${record.sessionType}`) } @@ -239,7 +242,7 @@ export class FreshAgentRuntimeManager { } async fork(locator: FreshAgentSessionLocator, input?: Record) { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.fork) { throw new FreshAgentUnsupportedCapabilityError(`Fork is not supported for ${record.sessionType}`) } @@ -263,7 +266,7 @@ export class FreshAgentRuntimeManager { } async answerQuestion(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, answers: Record) { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.answerQuestion) { throw new FreshAgentUnsupportedCapabilityError(`Questions are not supported for ${record.sessionType}`) } @@ -271,7 +274,7 @@ export class FreshAgentRuntimeManager { } async resolveApproval(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, decision: Record) { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) if (!record.adapter.resolveApproval) { throw new FreshAgentUnsupportedCapabilityError(`Approvals are not supported for ${record.sessionType}`) } @@ -377,6 +380,92 @@ export class FreshAgentRuntimeManager { return makeFreshAgentSessionKey(locator) } + private canRecoverFreshOpenCode( + locator: FreshAgentSessionLocator, + ): locator is FreshAgentSessionLocator & { cwd: string } { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + && typeof locator.cwd === 'string' + && locator.cwd.trim().length > 0 + } + + private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promise { + const key = this.key(locator) + const existing = this.sessions.get(key) + if (existing) { + if (existing.sessionType !== locator.sessionType || existing.runtimeProvider !== locator.provider) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is tracked as ${existing.sessionType}/${existing.runtimeProvider}, not ${locator.sessionType}/${locator.provider}`, + ) + } + if (this.canRecoverFreshOpenCode(locator)) { + if (existing.freshOpenCodeRouteCwd && existing.freshOpenCodeRouteCwd !== locator.cwd) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is tracked for ${existing.freshOpenCodeRouteCwd}, not ${locator.cwd}`, + ) + } + if (!existing.freshOpenCodeRouteCwd) { + if (!existing.adapter.attach) { + return existing + } + return await this.singleflightFreshOpenCodeAttach(locator, existing) + } + } + return existing + } + if (!this.canRecoverFreshOpenCode(locator)) { + return this.requireSession(locator) + } + const registration = this.requireRegistration(locator.sessionType, locator.provider) + if (!registration.adapter.attach) { + return this.requireSession(locator) + } + return await this.singleflightFreshOpenCodeAttach(locator) + } + + private async singleflightFreshOpenCodeAttach( + locator: FreshAgentSessionLocator & { cwd: string }, + existingRecord?: SessionRecord, + ): Promise { + const key = this.key(locator) + const pending = this.freshOpencodeRecoveries.get(key) + if (pending) { + if (pending.cwd !== locator.cwd) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${locator.sessionId} is already being recovered for ${pending.cwd}, not ${locator.cwd}`, + ) + } + return await pending.promise + } + + const record: SessionRecord = existingRecord ?? (() => { + const registration = this.requireRegistration(locator.sessionType, locator.provider) + return { + sessionType: locator.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + } + })() + if (!record.adapter.attach) { + return record + } + + const promise = Promise.resolve(record.adapter.attach(locator)).then(() => { + record.freshOpenCodeRouteCwd = locator.cwd + this.sessions.set(key, record) + return record + }) + this.freshOpencodeRecoveries.set(key, { cwd: locator.cwd, promise }) + try { + return await promise + } finally { + if (this.freshOpencodeRecoveries.get(key)?.promise === promise) { + this.freshOpencodeRecoveries.delete(key) + } + } + } + private requireSession(locator: FreshAgentSessionLocator): SessionRecord { const record = this.sessions.get(this.key(locator)) if (!record) { diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index 35606cd0..ba132e36 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it, vi } from 'vitest' import { FreshAgentRuntimeManager } from '../../../../server/fresh-agent/runtime-manager.js' import { createFreshAgentProviderRegistry } from '../../../../server/fresh-agent/provider-registry.js' +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + function makeSnapshot(sessionType: 'freshclaude' | 'kilroy', provider: 'claude', threadId: string) { return { sessionType, @@ -289,6 +299,228 @@ describe('FreshAgentRuntimeManager', () => { expect(opencodeAdapter.compact).toHaveBeenCalledWith('opencode-restored-1', { instructions: 'keep decisions' }) }) + it('recovers a missing FreshOpenCode durable session with cwd before mutation', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_restored' }), + send: vi.fn().mockResolvedValue(undefined), + interrupt: vi.fn().mockResolvedValue(undefined), + compact: vi.fn().mockResolvedValue(undefined), + fork: vi.fn().mockResolvedValue({ sessionId: 'ses_child' }), + answerQuestion: vi.fn().mockResolvedValue(undefined), + resolveApproval: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([{ + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }]) + const manager = new FreshAgentRuntimeManager({ registry }) + const locator = { + sessionId: 'ses_restored', + sessionType: 'freshopencode' as const, + provider: 'opencode' as const, + cwd: '/repo/safe', + } + + await manager.send(locator, { text: 'continue' }) + await manager.interrupt(locator) + await manager.compact(locator, { instructions: 'keep decisions' }) + await manager.fork(locator) + await manager.answerQuestion(locator, 'req-question', { choice: 'yes' }) + await manager.resolveApproval(locator, 'req-approval', { action: 'approve' }) + + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(1) + expect(opencodeAdapter.attach).toHaveBeenCalledWith(locator) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_restored', { text: 'continue' }) + expect(opencodeAdapter.interrupt).toHaveBeenCalledWith('ses_restored') + expect(opencodeAdapter.compact).toHaveBeenCalledWith('ses_restored', { instructions: 'keep decisions' }) + expect(opencodeAdapter.fork).toHaveBeenCalledWith('ses_restored', undefined) + expect(opencodeAdapter.answerQuestion).toHaveBeenCalledWith('ses_restored', 'req-question', { choice: 'yes' }) + expect(opencodeAdapter.resolveApproval).toHaveBeenCalledWith('ses_restored', 'req-approval', { action: 'approve' }) + }) + + it('does not recover placeholders, missing cwd, or non-OpenCode providers', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn(), + send: vi.fn(), + } + const codexAdapter = { + create: vi.fn(), + attach: vi.fn(), + send: vi.fn(), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await expect(manager.send({ + sessionId: 'freshopencode-temp', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, { text: 'no' })).rejects.toThrow(/not tracked|not available/i) + await expect(manager.send({ + sessionId: 'ses_missing_cwd', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'no' })).rejects.toThrow(/not tracked|cwd|not available/i) + await expect(manager.send({ + sessionId: 'codex-thread', + sessionType: 'freshcodex', + provider: 'codex', + }, { text: 'no' })).rejects.toThrow(/not tracked/i) + + expect(opencodeAdapter.attach).not.toHaveBeenCalled() + expect(codexAdapter.attach).not.toHaveBeenCalled() + }) + + it('enriches an existing FreshOpenCode record with cwd before mutating', async () => { + const opencodeAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ requestId: 'req-existing', sessionType: 'freshopencode', provider: 'opencode', prompt: 'start' } as any) + await manager.send({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, { text: 'continue' }) + + expect(opencodeAdapter.attach).toHaveBeenCalledWith({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_existing', { text: 'continue' }) + }) + + it('singleflights concurrent FreshOpenCode recovery for the same cwd', async () => { + const attachDeferred = createDeferred<{ sessionId: string }>() + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn(() => attachDeferred.promise), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + const locator = { + sessionId: 'ses_one', + sessionType: 'freshopencode' as const, + provider: 'opencode' as const, + cwd: '/repo', + } + + const first = manager.send(locator, { text: 'one' }) + const second = manager.send(locator, { text: 'two' }) + + await Promise.resolve() + + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(1) + + attachDeferred.resolve({ sessionId: 'ses_one' }) + + await Promise.all([first, second]) + + expect(opencodeAdapter.send).toHaveBeenCalledTimes(2) + }) + + it('rejects concurrent FreshOpenCode recovery when the same session key arrives with a different cwd', async () => { + const attachDeferred = createDeferred<{ sessionId: string }>() + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn(() => attachDeferred.promise), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + const first = manager.send({ + sessionId: 'ses_one', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/one', + }, { text: 'one' }) + + await Promise.resolve() + + await expect(manager.send({ + sessionId: 'ses_one', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/two', + }, { text: 'two' })).rejects.toThrow('/repo/one') + + attachDeferred.resolve({ sessionId: 'ses_one' }) + await first + }) + + it('rejects a later FreshOpenCode mutation when the tracked cwd differs', async () => { + const opencodeAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_existing' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ requestId: 'req-existing-cwd', sessionType: 'freshopencode', provider: 'opencode' } as any) + await manager.send({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/one', + }, { text: 'continue' }) + + await expect(manager.send({ + sessionId: 'ses_existing', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/two', + }, { text: 'mismatch' })).rejects.toThrow('/repo/one') + }) + it('routes freshAgent.kill through the tracked adapter and removes the session', async () => { const claudeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'claude-session-1' }), From 8767d8f882a7b2b48749409c27a15c6fd970d430 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 22:42:45 -0700 Subject: [PATCH 08/27] fix: enforce FreshOpenCode runtime route proof --- server/fresh-agent/runtime-manager.ts | 81 +++++++++++++++--- .../fresh-agent/runtime-manager.test.ts | 82 +++++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index 8ac363d1..5f83f322 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -91,6 +91,7 @@ type SessionRecord = { runtimeProvider: FreshAgentRuntimeProvider adapter: FreshAgentRuntimeAdapter freshOpenCodeRouteCwd?: string + freshOpenCodeProviderOwnedNoRoute?: boolean } export class FreshAgentRuntimeManager { @@ -112,11 +113,14 @@ export class FreshAgentRuntimeManager { sessionType: input.sessionType, provider: registration.runtimeProvider, sessionId: created.sessionId, - }), { + }), this.recordForSession({ sessionType: input.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, - }) + sessionId: created.sessionId, + cwd: input.cwd, + providerOwned: true, + })) return { sessionId: created.sessionId, sessionType: input.sessionType, @@ -132,12 +136,14 @@ export class FreshAgentRuntimeManager { : { sessionId: input.sessionId } const sessionId = attached.sessionId - this.sessions.set(this.key({ ...input, sessionId }), { + this.sessions.set(this.key({ ...input, sessionId }), this.recordForSession({ sessionType: input.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, - freshOpenCodeRouteCwd: this.canRecoverFreshOpenCode({ ...input, sessionId }) ? input.cwd : undefined, - }) + sessionId, + cwd: input.cwd, + providerOwned: false, + })) return { sessionId, @@ -157,11 +163,14 @@ export class FreshAgentRuntimeManager { sessionType: input.sessionType, provider: registration.runtimeProvider, sessionId: resumed.sessionId, - }), { + }), this.recordForSession({ sessionType: input.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, - }) + sessionId: resumed.sessionId, + cwd: input.cwd, + providerOwned: false, + })) return { sessionId: resumed.sessionId, sessionType: input.sessionType, @@ -199,7 +208,14 @@ export class FreshAgentRuntimeManager { sessionType: locator.sessionType, provider: record.runtimeProvider, sessionId: result.sessionId, - }), record) + }), this.recordForSession({ + sessionType: record.sessionType, + runtimeProvider: record.runtimeProvider, + adapter: record.adapter, + sessionId: result.sessionId, + cwd: this.routeCwd(locator) ?? this.routeCwd(input.settings) ?? record.freshOpenCodeRouteCwd, + providerOwned: true, + })) } if (result?.requestId) { return result @@ -260,7 +276,13 @@ export class FreshAgentRuntimeManager { sessionType: locator.sessionType, provider: record.runtimeProvider, sessionId: childSessionId, - }), record) + }), this.recordForSession({ + sessionType: record.sessionType, + runtimeProvider: record.runtimeProvider, + adapter: record.adapter, + sessionId: childSessionId, + providerOwned: Boolean(record.freshOpenCodeProviderOwnedNoRoute && !record.freshOpenCodeRouteCwd), + })) } return forked } @@ -386,8 +408,38 @@ export class FreshAgentRuntimeManager { return locator.sessionType === 'freshopencode' && locator.provider === 'opencode' && locator.sessionId.startsWith('ses_') - && typeof locator.cwd === 'string' - && locator.cwd.trim().length > 0 + && this.routeCwd(locator) !== undefined + } + + private isDurableFreshOpenCode(locator: Pick): boolean { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + } + + private routeCwd(input?: { cwd?: string }): string | undefined { + return typeof input?.cwd === 'string' && input.cwd.trim().length > 0 ? input.cwd : undefined + } + + private recordForSession(input: { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter + sessionId: string + cwd?: string + providerOwned: boolean + }): SessionRecord { + const base: SessionRecord = { + sessionType: input.sessionType, + runtimeProvider: input.runtimeProvider, + adapter: input.adapter, + } + const provider = input.runtimeProvider + const locator = { sessionType: input.sessionType, provider, sessionId: input.sessionId } + if (!this.isDurableFreshOpenCode(locator)) return base + const cwd = this.routeCwd(input) + if (cwd) return { ...base, freshOpenCodeRouteCwd: cwd } + return input.providerOwned ? { ...base, freshOpenCodeProviderOwnedNoRoute: true } : base } private async requireOrRecoverSession(locator: FreshAgentSessionLocator): Promise { @@ -411,6 +463,12 @@ export class FreshAgentRuntimeManager { } return await this.singleflightFreshOpenCodeAttach(locator, existing) } + } else if (this.isDurableFreshOpenCode(locator) + && !existing.freshOpenCodeRouteCwd + && !existing.freshOpenCodeProviderOwnedNoRoute) { + throw new FreshAgentLostSessionError( + `Fresh-agent session ${locator.sessionType}/${locator.provider}/${locator.sessionId} requires a cwd before mutation`, + ) } return existing } @@ -453,6 +511,7 @@ export class FreshAgentRuntimeManager { const promise = Promise.resolve(record.adapter.attach(locator)).then(() => { record.freshOpenCodeRouteCwd = locator.cwd + record.freshOpenCodeProviderOwnedNoRoute = false this.sessions.set(key, record) return record }) diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index ba132e36..6cb734ff 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -253,6 +253,36 @@ describe('FreshAgentRuntimeManager', () => { expect(opencodeAdapter.send).toHaveBeenNthCalledWith(3, 'ses_real_1', { text: 'via real id' }) }) + it('rejects a tracked recovered durable FreshOpenCode session without cwd before mutation', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_recovered_no_route' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.attach({ + sessionId: 'ses_recovered_no_route', + sessionType: 'freshopencode', + provider: 'opencode', + }) + + await expect(manager.send({ + sessionId: 'ses_recovered_no_route', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'must not send' })).rejects.toThrow(/cwd|route|not tracked|not available/i) + + expect(opencodeAdapter.send).not.toHaveBeenCalled() + }) + it('hydrates adapter state when attaching a restored session before send and compact', async () => { const opencodeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'opencode-created-1' }), @@ -521,6 +551,58 @@ describe('FreshAgentRuntimeManager', () => { }, { text: 'mismatch' })).rejects.toThrow('/repo/one') }) + it('keeps forked FreshOpenCode child route state independent from the parent', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_parent' }), + fork: vi.fn().mockResolvedValue({ sessionId: 'ses_child' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.fork({ + sessionId: 'ses_parent', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/parent', + }) + + await manager.send({ + sessionId: 'ses_child', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/child', + }, { text: 'child route' }) + await manager.send({ + sessionId: 'ses_parent', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/parent', + }, { text: 'parent route' }) + + expect(opencodeAdapter.attach).toHaveBeenCalledWith({ + sessionId: 'ses_parent', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/parent', + }) + expect(opencodeAdapter.attach).toHaveBeenCalledWith({ + sessionId: 'ses_child', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/child', + }) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_child', { text: 'child route' }) + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_parent', { text: 'parent route' }) + }) + it('routes freshAgent.kill through the tracked adapter and removes the session', async () => { const claudeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'claude-session-1' }), From 15a7daaef9da052d2e01dfc2165028036027d93d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 14:41:15 -0700 Subject: [PATCH 09/27] test: prove resumed OpenCode sessions need route proof --- .../fresh-agent/runtime-manager.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index 6cb734ff..3b08c515 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -283,6 +283,38 @@ describe('FreshAgentRuntimeManager', () => { expect(opencodeAdapter.send).not.toHaveBeenCalled() }) + it('keeps a directly resumed no-cwd durable FreshOpenCode session read-only until cwd is supplied', async () => { + const opencodeAdapter = { + create: vi.fn(), + resume: vi.fn().mockResolvedValue({ sessionId: 'ses_resumed_no_route' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.resume({ + requestId: 'req-resume-no-route', + sessionType: 'freshopencode', + provider: 'opencode', + resumeSessionId: 'ses_resumed_no_route', + }) + + await expect(manager.send({ + sessionId: 'ses_resumed_no_route', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'must not send' })).rejects.toThrow(/cwd|route|not tracked|not available/i) + + expect(opencodeAdapter.resume).toHaveBeenCalled() + expect(opencodeAdapter.send).not.toHaveBeenCalled() + }) + it('hydrates adapter state when attaching a restored session before send and compact', async () => { const opencodeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'opencode-created-1' }), From 4aaf3457c6ba68c516e5ffa278e2ef3f32271111 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 14:45:33 -0700 Subject: [PATCH 10/27] fix: mark forked OpenCode children provider-owned --- server/fresh-agent/runtime-manager.ts | 2 +- .../fresh-agent/runtime-manager.test.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index 5f83f322..851dd0e6 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -281,7 +281,7 @@ export class FreshAgentRuntimeManager { runtimeProvider: record.runtimeProvider, adapter: record.adapter, sessionId: childSessionId, - providerOwned: Boolean(record.freshOpenCodeProviderOwnedNoRoute && !record.freshOpenCodeRouteCwd), + providerOwned: true, })) } return forked diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index 3b08c515..ec7751dc 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -635,6 +635,37 @@ describe('FreshAgentRuntimeManager', () => { expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_parent', { text: 'parent route' }) }) + it('keeps provider-forked FreshOpenCode children mutable without cwd in this process', async () => { + const opencodeAdapter = { + create: vi.fn(), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_parent' }), + fork: vi.fn().mockResolvedValue({ sessionId: 'ses_child' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.fork({ + sessionId: 'ses_parent', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/parent', + }) + await manager.send({ + sessionId: 'ses_child', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'child no route' }) + + expect(opencodeAdapter.send).toHaveBeenCalledWith('ses_child', { text: 'child no route' }) + }) + it('routes freshAgent.kill through the tracked adapter and removes the session', async () => { const claudeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'claude-session-1' }), From 4360080a17d7670473dca93229caf3c9d9782e71 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 14:49:20 -0700 Subject: [PATCH 11/27] fix: keep resumed OpenCode sessions route-gated --- server/fresh-agent/runtime-manager.ts | 7 ++-- .../fresh-agent/runtime-manager.test.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index 851dd0e6..91cbe1a5 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -106,8 +106,9 @@ export class FreshAgentRuntimeManager { ?? (input.sessionRef?.provider === registration.runtimeProvider ? input.sessionRef.sessionId : undefined) const createInput = resumeSessionId ? { ...input, resumeSessionId } : input - const created = resumeSessionId && registration.adapter.resume - ? await registration.adapter.resume(createInput) + const usedResume = Boolean(resumeSessionId && registration.adapter.resume) + const created = usedResume + ? await registration.adapter.resume!(createInput) : await registration.adapter.create(createInput) this.sessions.set(this.key({ sessionType: input.sessionType, @@ -119,7 +120,7 @@ export class FreshAgentRuntimeManager { adapter: registration.adapter, sessionId: created.sessionId, cwd: input.cwd, - providerOwned: true, + providerOwned: !usedResume, })) return { sessionId: created.sessionId, diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index ec7751dc..0a937397 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -315,6 +315,39 @@ describe('FreshAgentRuntimeManager', () => { expect(opencodeAdapter.send).not.toHaveBeenCalled() }) + it('keeps create-resumed no-cwd durable FreshOpenCode sessions read-only until cwd is supplied', async () => { + const opencodeAdapter = { + create: vi.fn(), + resume: vi.fn().mockResolvedValue({ sessionId: 'ses_create_resumed_no_route' }), + send: vi.fn().mockResolvedValue(undefined), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.create({ + requestId: 'req-create-resume-no-route', + sessionType: 'freshopencode', + provider: 'opencode', + resumeSessionId: 'ses_create_resumed_no_route', + }) + + await expect(manager.send({ + sessionId: 'ses_create_resumed_no_route', + sessionType: 'freshopencode', + provider: 'opencode', + }, { text: 'must not send' })).rejects.toThrow(/cwd|route|not tracked|not available/i) + + expect(opencodeAdapter.resume).toHaveBeenCalled() + expect(opencodeAdapter.create).not.toHaveBeenCalled() + expect(opencodeAdapter.send).not.toHaveBeenCalled() + }) + it('hydrates adapter state when attaching a restored session before send and compact', async () => { const opencodeAdapter = { create: vi.fn().mockResolvedValue({ sessionId: 'opencode-created-1' }), From d9784d7fa1a44554bb18f6a634d23355e22615df Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 14:59:42 -0700 Subject: [PATCH 12/27] Add route-aware fresh-agent WS authorization --- server/ws-handler.ts | 176 +++++++++++++----- shared/ws-protocol.ts | 10 +- .../ws-handler-fresh-agent-ownership.test.ts | 145 +++++++++++++++ .../server/ws-handler-fresh-agent.test.ts | 76 +++++++- 4 files changed, 356 insertions(+), 51 deletions(-) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index f2be1798..cab27af8 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -88,7 +88,11 @@ import { } from '../shared/ws-protocol.js' import { LiveTerminalHandleSchema, sanitizeSessionRef, type RestoreError } from '../shared/session-contract.js' import { CODEX_DURABILITY_SCHEMA_VERSION, CodexDurabilityRefSchema } from '../shared/codex-durability.js' -import { migrateLegacyFreshAgentContent } from '../shared/fresh-agent.js' +import { + migrateLegacyFreshAgentContent, + type FreshAgentRuntimeProvider, + type FreshAgentSessionType, +} from '../shared/fresh-agent.js' import { UiLayoutSyncSchema } from './agent-api/layout-schema.js' import type { LayoutStore } from './agent-api/layout-store.js' import { @@ -144,6 +148,7 @@ type FreshAgentLocator = { sessionId: string sessionType: string provider: string + cwd?: string } type FreshAgentCreatedRecord = { @@ -151,6 +156,7 @@ type FreshAgentCreatedRecord = { sessionType: string provider: string runtimeProvider: string + cwd?: string sessionRef?: { provider: string; sessionId: string } } @@ -445,7 +451,8 @@ type ClientState = { codingCliSessions: Set codingCliSubscriptions: Map void> freshAgentSubscriptions: Map - freshAgentAuthorizations: Set + freshAgentAuthorizations: Map + pendingFreshAgentAttachByKey: Map }> wsErrorLogs: Map interestedSessions: Set sidebarOpenSessionKeys: Set @@ -1096,7 +1103,8 @@ export class WsHandler { codingCliSessions: new Set(), codingCliSubscriptions: new Map(), freshAgentSubscriptions: new Map(), - freshAgentAuthorizations: new Set(), + freshAgentAuthorizations: new Map(), + pendingFreshAgentAttachByKey: new Map(), wsErrorLogs: new Map(), interestedSessions: new Set(), sidebarOpenSessionKeys: new Set(), @@ -1184,6 +1192,38 @@ export class WsHandler { return `${locator.sessionType}:${locator.provider}:${locator.sessionId}` } + private requiresFreshOpenCodeRouteAuthorization(locator: FreshAgentLocator): boolean { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + && typeof locator.cwd === 'string' + && locator.cwd.trim().length > 0 + } + + private freshAgentAuthorizationKey(locator: FreshAgentLocator): string { + if (!this.requiresFreshOpenCodeRouteAuthorization(locator)) return this.freshAgentKey(locator) + const cwd = typeof locator.cwd === 'string' && locator.cwd.trim().length > 0 ? locator.cwd : '' + return `${this.freshAgentKey(locator)}:${cwd}` + } + + private freshAgentLocatorFromMessage(m: { + sessionId: string + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + cwd?: string + settings?: { cwd?: string } + }): FreshAgentLocator { + const cwd = typeof m.cwd === 'string' && m.cwd.trim().length > 0 + ? m.cwd + : (typeof m.settings?.cwd === 'string' && m.settings.cwd.trim().length > 0 ? m.settings.cwd : undefined) + return { + sessionId: m.sessionId, + sessionType: m.sessionType, + provider: m.provider, + ...(cwd ? { cwd } : {}), + } + } + private freshAgentEventMessage(locator: FreshAgentLocator, event: unknown) { return { type: 'freshAgent.event', @@ -1217,11 +1257,13 @@ export class WsHandler { } private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void { - state.freshAgentAuthorizations.add(this.freshAgentKey(locator)) + state.freshAgentAuthorizations.set(this.freshAgentAuthorizationKey(locator), { + ...(locator.cwd ? { cwd: locator.cwd } : {}), + }) } private isFreshAgentAuthorized(state: ClientState, locator: FreshAgentLocator): boolean { - return state.freshAgentAuthorizations.has(this.freshAgentKey(locator)) + return state.freshAgentAuthorizations.has(this.freshAgentAuthorizationKey(locator)) } private requireFreshAgentAuthorization( @@ -1237,6 +1279,26 @@ export class WsHandler { return false } + private async waitForFreshAgentAuthorization( + ws: LiveWebSocket, + state: ClientState, + locator: FreshAgentLocator, + requestId?: string, + ): Promise { + if (this.isFreshAgentAuthorized(state, locator)) return true + const pending = state.pendingFreshAgentAttachByKey.get(this.freshAgentAuthorizationKey(locator)) + if (pending) { + await pending.promise.catch(() => undefined) + if (this.isFreshAgentAuthorized(state, locator)) return true + } + this.sendError(ws, { + code: 'UNAUTHORIZED', + message: 'Not authorized for this Fresh Agent session', + ...(requestId ? { requestId } : {}), + }) + return false + } + private freshAgentUnavailableMessage() { return 'Fresh Agent runtime is not enabled' } @@ -1289,6 +1351,7 @@ export class WsHandler { sessionId: materialized.sessionId, sessionType: locator.sessionType, provider: locator.provider, + ...(locator.cwd ? { cwd: locator.cwd } : {}), } this.authorizeFreshAgentSession(state, materializedLocator) this.ensureFreshAgentSubscription(ws, state, materializedLocator) @@ -3102,21 +3165,23 @@ export class WsHandler { await this.withFreshAgentCreateLock(m.requestId, async () => { const cached = this.createdFreshAgentByRequestId.get(m.requestId) if (cached) { - this.authorizeFreshAgentSession(state, { + const cachedLocator = { sessionId: cached.sessionId, sessionType: cached.sessionType, provider: cached.runtimeProvider, - }) + ...(cached.cwd ? { cwd: cached.cwd } : {}), + } + this.authorizeFreshAgentSession(state, cachedLocator) this.send(ws, { type: 'freshAgent.created', requestId: m.requestId, - ...cached, - }) - this.ensureFreshAgentSubscription(ws, state, { sessionId: cached.sessionId, sessionType: cached.sessionType, - provider: cached.runtimeProvider, + provider: cached.provider, + runtimeProvider: cached.runtimeProvider, + ...(cached.sessionRef ? { sessionRef: cached.sessionRef } : {}), }) + this.ensureFreshAgentSubscription(ws, state, cachedLocator) return } @@ -3159,24 +3224,27 @@ export class WsHandler { sessionType: result.sessionType ?? m.sessionType, provider: runtimeProvider, runtimeProvider, + ...(m.cwd ? { cwd: m.cwd } : {}), ...(result.sessionRef ? { sessionRef: result.sessionRef } : {}), } this.createdFreshAgentByRequestId.set(m.requestId, record) - this.authorizeFreshAgentSession(state, { + const recordLocator = { sessionId: record.sessionId, sessionType: record.sessionType, provider: record.runtimeProvider, - }) + ...(record.cwd ? { cwd: record.cwd } : {}), + } + this.authorizeFreshAgentSession(state, recordLocator) this.send(ws, { type: 'freshAgent.created', requestId: m.requestId, - ...record, - }) - this.ensureFreshAgentSubscription(ws, state, { sessionId: record.sessionId, sessionType: record.sessionType, - provider: record.runtimeProvider, + provider: record.provider, + runtimeProvider: record.runtimeProvider, + ...(record.sessionRef ? { sessionRef: record.sessionRef } : {}), }) + this.ensureFreshAgentSubscription(ws, state, recordLocator) } catch (error) { log.warn({ err: error instanceof Error ? error : new Error(String(error)), @@ -3205,22 +3273,33 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { - sessionId: m.sessionId, - sessionType: m.sessionType, - provider: m.provider, - ...(m.cwd ? { cwd: m.cwd } : {}), - } + const locator = this.freshAgentLocatorFromMessage(m) + const authorizationKey = this.freshAgentAuthorizationKey(locator) + const attachPromise = Promise.resolve(manager.attach({ + ...locator, + ...(m.sessionRef ? { sessionRef: m.sessionRef } : {}), + })) + .then(() => { + this.authorizeFreshAgentSession(state, locator) + this.ensureFreshAgentSubscription(ws, state, locator) + }) + state.pendingFreshAgentAttachByKey.set(authorizationKey, { + ...(locator.cwd ? { cwd: locator.cwd } : {}), + promise: attachPromise, + }) try { - await Promise.resolve(manager.attach(locator)) - this.authorizeFreshAgentSession(state, locator) - this.ensureFreshAgentSubscription(ws, state, locator) + await attachPromise } catch (error) { log.warn({ err: error instanceof Error ? error : new Error(String(error)), ...locator, }, 'freshAgent.attach failed') this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + } finally { + const pending = state.pendingFreshAgentAttachByKey.get(authorizationKey) + if (pending?.promise === attachPromise) { + state.pendingFreshAgentAttachByKey.delete(authorizationKey) + } } return } @@ -3231,8 +3310,8 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } - if (!this.requireFreshAgentAuthorization(ws, state, locator)) return + const locator = this.freshAgentLocatorFromMessage(m) + if (!await this.waitForFreshAgentAuthorization(ws, state, locator, m.requestId)) return try { const result = await manager.send(locator, { requestId: m.requestId, @@ -3241,16 +3320,14 @@ export class WsHandler { settings: m.settings, }) if (result?.sessionId && result.sessionId !== m.sessionId) { - this.authorizeFreshAgentSession(state, { + const materializedLocator = { sessionId: result.sessionId, sessionType: m.sessionType, provider: m.provider, - }) - this.ensureFreshAgentSubscription(ws, state, { - sessionId: result.sessionId, - sessionType: m.sessionType, - provider: m.provider, - }) + ...(locator.cwd ? { cwd: locator.cwd } : {}), + } + this.authorizeFreshAgentSession(state, materializedLocator) + this.ensureFreshAgentSubscription(ws, state, materializedLocator) this.cancelFreshAgentSubscription(state, locator) this.send(ws, { type: 'freshAgent.session.materialized', @@ -3265,6 +3342,9 @@ export class WsHandler { this.send(ws, { type: 'freshAgent.send.accepted', requestId: m.requestId, + sessionId: locator.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, submittedTurnId: result?.submittedTurnId, }) } @@ -3280,7 +3360,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { await manager.interrupt(locator) @@ -3296,7 +3376,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { await manager.compact(locator, { instructions: m.instructions }) @@ -3312,7 +3392,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { await manager.resolveApproval(locator, m.requestId, m.decision) @@ -3328,7 +3408,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { await manager.answerQuestion(locator, m.requestId, m.answers) @@ -3344,7 +3424,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { const forked = await manager.fork(locator, m.input) @@ -3353,11 +3433,13 @@ export class WsHandler { ? forkedRecord.threadId : (typeof forkedRecord.sessionId === 'string' ? forkedRecord.sessionId : undefined) if (forkedSessionId) { - this.authorizeFreshAgentSession(state, { + const forkedLocator = { sessionId: forkedSessionId, sessionType: m.sessionType, provider: m.provider, - }) + ...(locator.cwd ? { cwd: locator.cwd } : {}), + } + this.authorizeFreshAgentSession(state, forkedLocator) this.send(ws, { type: 'freshAgent.forked', requestId: m.requestId, @@ -3368,11 +3450,7 @@ export class WsHandler { runtimeProvider: m.provider, sessionRef: { provider: m.provider, sessionId: forkedSessionId }, }) - this.ensureFreshAgentSubscription(ws, state, { - sessionId: forkedSessionId, - sessionType: m.sessionType, - provider: m.provider, - }) + this.ensureFreshAgentSubscription(ws, state, forkedLocator) } } catch (error) { this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) @@ -3386,13 +3464,13 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() }) return } - const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } + const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return this.cancelFreshAgentSubscription(state, locator) try { const success = await manager.kill(locator) this.clearFreshAgentCreateCachesForSession(m.sessionId) - state.freshAgentAuthorizations.delete(this.freshAgentKey(locator)) + state.freshAgentAuthorizations.delete(this.freshAgentAuthorizationKey(locator)) this.send(ws, { type: 'freshAgent.killed', sessionId: m.sessionId, diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 0b7df212..43752a03 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -426,6 +426,7 @@ export const FreshAgentAttachSchema = z.object({ provider: z.enum(['claude', 'codex', 'opencode']), resumeSessionId: z.string().optional(), cwd: z.string().optional(), + sessionRef: SessionLocatorSchema.optional(), }) export const FreshAgentSendSchema = z.object({ @@ -434,6 +435,7 @@ export const FreshAgentSendSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), text: z.string().min(1), settings: z.object({ cwd: z.string().min(1).optional(), @@ -453,6 +455,7 @@ export const FreshAgentInterruptSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), }) export const FreshAgentCompactSchema = z.object({ @@ -460,6 +463,7 @@ export const FreshAgentCompactSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), instructions: z.string().trim().min(1).optional(), }) @@ -468,6 +472,7 @@ export const FreshAgentApprovalRespondSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), requestId: z.union([z.string().min(1), z.number().int()]), decision: z.record(z.string(), z.unknown()), }) @@ -477,6 +482,7 @@ export const FreshAgentQuestionRespondSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), requestId: z.union([z.string().min(1), z.number().int()]), answers: z.record(z.string(), z.string()), }) @@ -486,6 +492,7 @@ export const FreshAgentKillSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), }) export const FreshAgentForkSchema = z.object({ @@ -494,6 +501,7 @@ export const FreshAgentForkSchema = z.object({ sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), input: z.record(z.string(), z.unknown()).optional(), }) @@ -866,7 +874,7 @@ export type SdkRestoreFailureCode = export type FreshAgentServerMessage = | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean } - | { type: 'freshAgent.send.accepted'; requestId: string; submittedTurnId?: string } + | { type: 'freshAgent.send.accepted'; requestId: string; sessionId: string; sessionType: string; provider: string; submittedTurnId?: string } | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown } | { type: 'freshAgent.session.materialized'; previousSessionId: string; sessionId: string; sessionType: string; provider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.forked'; requestId?: string; parentSessionId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } diff --git a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index fb26dbf7..f5d3a940 100644 --- a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts @@ -17,6 +17,16 @@ import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol.js' const TEST_AUTH_TOKEN = 'testtoken-testtoken' +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + function enabledConfig() { const settings = createDefaultServerSettings({ loggingDebug: false }) settings.freshAgent.enabled = true @@ -189,4 +199,139 @@ describe('WsHandler fresh-agent ownership', () => { await new Promise((resolve) => server.close(() => resolve())) } }) + + it('waits for same-socket attach before sending a raced FreshOpenCode prompt', async () => { + const attach = createDeferred<{ sessionId: string; runtimeProvider: string }>() + const runtimeManager = { + attach: vi.fn(() => attach.promise), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + sessionRef: { provider: 'opencode', sessionId: 'ses_race' }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-after-attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + text: 'continue', + })) + + await vi.waitFor(() => expect(runtimeManager.attach).toHaveBeenCalled()) + expect(runtimeManager.send).not.toHaveBeenCalled() + + attach.resolve({ sessionId: 'ses_race', runtimeProvider: 'opencode' }) + + await vi.waitFor(() => { + expect(runtimeManager.send).toHaveBeenCalledWith({ + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }, expect.objectContaining({ + requestId: 'send-after-attach', + text: 'continue', + })) + }) + expect(messages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.send.accepted', + requestId: 'send-after-attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + })) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it('fails closed when a raced durable FreshOpenCode send uses a different cwd than the attach', async () => { + const attach = createDeferred<{ sessionId: string; runtimeProvider: string }>() + const runtimeManager = { + attach: vi.fn(() => attach.promise), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + sessionRef: { provider: 'opencode', sessionId: 'ses_race' }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-mismatch', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/b', + text: 'mismatch', + })) + + await vi.waitFor(() => expect(runtimeManager.attach).toHaveBeenCalled()) + expect(runtimeManager.send).not.toHaveBeenCalled() + + attach.resolve({ sessionId: 'ses_race', runtimeProvider: 'opencode' }) + + await vi.waitFor(() => { + expect(messages).toContainEqual(expect.objectContaining({ + type: 'error', + code: 'UNAUTHORIZED', + requestId: 'send-mismatch', + })) + }) + expect(runtimeManager.send).not.toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/b', + }), expect.anything()) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-authorized', + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + text: 'authorized', + })) + + await vi.waitFor(() => { + expect(runtimeManager.send).toHaveBeenCalledWith({ + sessionId: 'ses_race', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + }, expect.objectContaining({ + requestId: 'send-authorized', + text: 'authorized', + })) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) }) diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 8a58efdf..36fb72e8 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -442,7 +442,10 @@ describe('WsHandler fresh-agent routing', () => { await vi.waitFor(() => { const locator = { sessionId: 'codex-session-2', sessionType: 'freshcodex', provider: 'codex' } - expect(runtimeManager.send).toHaveBeenCalledWith(locator, { + expect(runtimeManager.send).toHaveBeenCalledWith({ + ...locator, + cwd: '/repo', + }, { requestId: 'send-req-1', text: 'Ship it', images: undefined, @@ -460,6 +463,9 @@ describe('WsHandler fresh-agent routing', () => { expect(seenMessages).toContainEqual(expect.objectContaining({ type: 'freshAgent.send.accepted', requestId: 'send-req-1', + sessionId: 'codex-session-2', + sessionType: 'freshcodex', + provider: 'codex', submittedTurnId: 'display-user-1', })) expect(seenMessages).toContainEqual(expect.objectContaining({ @@ -478,6 +484,74 @@ describe('WsHandler fresh-agent routing', () => { } }) + it('allows non-FreshOpenCode sessions created without cwd to send with settings.cwd', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-cwd-upgrade', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue({ submittedTurnId: 'turn-1' }), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-cwd-upgrade', + sessionType: 'freshcodex', + provider: 'codex', + })) + + await vi.waitFor(() => { + expect(runtimeManager.create).toHaveBeenCalled() + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-cwd-upgrade', + sessionId: 'codex-session-cwd-upgrade', + sessionType: 'freshcodex', + provider: 'codex', + text: 'continue', + settings: { cwd: '/repo/allowed' }, + })) + + await vi.waitFor(() => { + expect(runtimeManager.send).toHaveBeenCalledWith({ + sessionId: 'codex-session-cwd-upgrade', + sessionType: 'freshcodex', + provider: 'codex', + cwd: '/repo/allowed', + }, { + requestId: 'send-cwd-upgrade', + text: 'continue', + images: undefined, + settings: { cwd: '/repo/allowed' }, + }) + expect(seenMessages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.send.accepted', + requestId: 'send-cwd-upgrade', + sessionId: 'codex-session-cwd-upgrade', + sessionType: 'freshcodex', + provider: 'codex', + submittedTurnId: 'turn-1', + })) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('emits freshAgent.session.materialized when send returns a new session id', async () => { const unsubscribeByKey = new Map>() const placeholderLocator = { sessionId: 'freshopencode-req-1', sessionType: 'freshopencode', provider: 'opencode' } From f145c252e87b3f194007cc38b4203273501021cd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:12:45 -0700 Subject: [PATCH 13/27] Fix fresh-agent materialization cleanup --- server/ws-handler.ts | 140 ++++++++++++++---- .../ws-handler-fresh-agent-ownership.test.ts | 76 ++++++++++ .../server/ws-handler-fresh-agent.test.ts | 106 +++++++++++++ 3 files changed, 292 insertions(+), 30 deletions(-) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index cab27af8..cc676086 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -158,6 +158,7 @@ type FreshAgentCreatedRecord = { runtimeProvider: string cwd?: string sessionRef?: { provider: string; sessionId: string } + sessionLineage: string[] } type FreshAgentSubscriptionEntry = { @@ -166,6 +167,13 @@ type FreshAgentSubscriptionEntry = { pending?: Promise } +type FreshAgentAuthorizationEntry = FreshAgentLocator + +type PendingFreshAgentAttachEntry = FreshAgentLocator & { + active: boolean + promise: Promise +} + type WsErrorLogEntry = { code: string messageClass: string @@ -451,8 +459,8 @@ type ClientState = { codingCliSessions: Set codingCliSubscriptions: Map void> freshAgentSubscriptions: Map - freshAgentAuthorizations: Map - pendingFreshAgentAttachByKey: Map }> + freshAgentAuthorizations: Map + pendingFreshAgentAttachByKey: Map wsErrorLogs: Map interestedSessions: Set sidebarOpenSessionKeys: Set @@ -977,7 +985,7 @@ export class WsHandler { private clearFreshAgentCreateCachesForSession(sessionId: string): void { for (const [requestId, cached] of this.createdFreshAgentByRequestId.entries()) { - if (cached.sessionId === sessionId) { + if (cached.sessionId === sessionId || cached.sessionLineage.includes(sessionId)) { this.createdFreshAgentByRequestId.delete(requestId) } } @@ -1257,15 +1265,92 @@ export class WsHandler { } private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void { - state.freshAgentAuthorizations.set(this.freshAgentAuthorizationKey(locator), { - ...(locator.cwd ? { cwd: locator.cwd } : {}), - }) + state.freshAgentAuthorizations.set(this.freshAgentAuthorizationKey(locator), { ...locator }) } private isFreshAgentAuthorized(state: ClientState, locator: FreshAgentLocator): boolean { return state.freshAgentAuthorizations.has(this.freshAgentAuthorizationKey(locator)) } + private sameFreshAgentSession( + left: Pick, + right: Pick, + ): boolean { + return left.sessionId === right.sessionId + && left.sessionType === right.sessionType + && left.provider === right.provider + } + + private retireFreshAgentAuthorizations(state: ClientState, locator: FreshAgentLocator): void { + for (const [key, authorization] of state.freshAgentAuthorizations.entries()) { + if (!this.sameFreshAgentSession(authorization, locator)) continue + state.freshAgentAuthorizations.delete(key) + } + } + + private retirePendingFreshAgentAttaches(state: ClientState, locator: FreshAgentLocator): void { + for (const [key, pending] of state.pendingFreshAgentAttachByKey.entries()) { + if (!this.sameFreshAgentSession(pending, locator)) continue + pending.active = false + state.pendingFreshAgentAttachByKey.delete(key) + } + } + + private retireFreshAgentSessionState(state: ClientState, locator: FreshAgentLocator): void { + this.cancelFreshAgentSubscription(state, locator) + this.retireFreshAgentAuthorizations(state, locator) + this.retirePendingFreshAgentAttaches(state, locator) + } + + private updateFreshAgentCreateCachesForMaterialization( + previousSessionIds: string[], + sessionId: string, + sessionRef?: { provider: string; sessionId: string }, + ): void { + const previousIds = new Set(previousSessionIds) + for (const [requestId, cached] of this.createdFreshAgentByRequestId.entries()) { + const lineage = cached.sessionLineage.length > 0 ? cached.sessionLineage : [cached.sessionId] + const matchesLineage = cached.sessionId === sessionId + || previousIds.has(cached.sessionId) + || lineage.some((lineageSessionId) => previousIds.has(lineageSessionId)) + if (!matchesLineage) continue + this.createdFreshAgentByRequestId.set(requestId, { + ...cached, + sessionId, + ...(sessionRef ? { sessionRef } : {}), + sessionLineage: Array.from(new Set([...lineage, ...previousIds, sessionId])), + }) + } + } + + private materializeFreshAgentSession( + ws: LiveWebSocket, + state: ClientState, + locator: FreshAgentLocator, + next: { + previousSessionId?: string + sessionId: string + sessionRef?: { provider: string; sessionId: string } + }, + ): FreshAgentLocator { + const materializedLocator = { + sessionId: next.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + ...(locator.cwd ? { cwd: locator.cwd } : {}), + } + const sessionRef = next.sessionRef ?? { provider: locator.provider, sessionId: next.sessionId } + this.retireFreshAgentSessionState(state, locator) + this.authorizeFreshAgentSession(state, materializedLocator) + this.ensureFreshAgentSubscription(ws, state, materializedLocator) + this.updateFreshAgentCreateCachesForMaterialization( + [locator.sessionId, ...(next.previousSessionId ? [next.previousSessionId] : [])], + next.sessionId, + sessionRef, + ) + return materializedLocator + } + private requireFreshAgentAuthorization( ws: LiveWebSocket, state: ClientState, @@ -1347,17 +1432,11 @@ export class WsHandler { if (!entry.active) return const materialized = this.freshAgentMaterializedMessage(locator, event) if (materialized) { - const materializedLocator = { + this.materializeFreshAgentSession(ws, state, locator, { + previousSessionId: materialized.previousSessionId, sessionId: materialized.sessionId, - sessionType: locator.sessionType, - provider: locator.provider, - ...(locator.cwd ? { cwd: locator.cwd } : {}), - } - this.authorizeFreshAgentSession(state, materializedLocator) - this.ensureFreshAgentSubscription(ws, state, materializedLocator) - if (materialized.sessionId !== locator.sessionId) { - this.cancelFreshAgentSubscription(state, locator) - } + sessionRef: materialized.sessionRef, + }) this.safeSend(ws, materialized) return } @@ -3226,6 +3305,7 @@ export class WsHandler { runtimeProvider, ...(m.cwd ? { cwd: m.cwd } : {}), ...(result.sessionRef ? { sessionRef: result.sessionRef } : {}), + sessionLineage: [result.sessionId], } this.createdFreshAgentByRequestId.set(m.requestId, record) const recordLocator = { @@ -3275,18 +3355,23 @@ export class WsHandler { } const locator = this.freshAgentLocatorFromMessage(m) const authorizationKey = this.freshAgentAuthorizationKey(locator) + let pendingEntry: PendingFreshAgentAttachEntry const attachPromise = Promise.resolve(manager.attach({ ...locator, ...(m.sessionRef ? { sessionRef: m.sessionRef } : {}), })) .then(() => { + const current = state.pendingFreshAgentAttachByKey.get(authorizationKey) + if (current !== pendingEntry || !current?.active) return this.authorizeFreshAgentSession(state, locator) this.ensureFreshAgentSubscription(ws, state, locator) }) - state.pendingFreshAgentAttachByKey.set(authorizationKey, { - ...(locator.cwd ? { cwd: locator.cwd } : {}), + pendingEntry = { + ...locator, + active: true, promise: attachPromise, - }) + } + state.pendingFreshAgentAttachByKey.set(authorizationKey, pendingEntry) try { await attachPromise } catch (error) { @@ -3297,7 +3382,7 @@ export class WsHandler { this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) } finally { const pending = state.pendingFreshAgentAttachByKey.get(authorizationKey) - if (pending?.promise === attachPromise) { + if (pending === pendingEntry) { state.pendingFreshAgentAttachByKey.delete(authorizationKey) } } @@ -3320,15 +3405,11 @@ export class WsHandler { settings: m.settings, }) if (result?.sessionId && result.sessionId !== m.sessionId) { - const materializedLocator = { + this.materializeFreshAgentSession(ws, state, locator, { + previousSessionId: m.sessionId, sessionId: result.sessionId, - sessionType: m.sessionType, - provider: m.provider, - ...(locator.cwd ? { cwd: locator.cwd } : {}), - } - this.authorizeFreshAgentSession(state, materializedLocator) - this.ensureFreshAgentSubscription(ws, state, materializedLocator) - this.cancelFreshAgentSubscription(state, locator) + sessionRef: result.sessionRef, + }) this.send(ws, { type: 'freshAgent.session.materialized', previousSessionId: m.sessionId, @@ -3466,11 +3547,10 @@ export class WsHandler { } const locator = this.freshAgentLocatorFromMessage(m) if (!this.requireFreshAgentAuthorization(ws, state, locator)) return - this.cancelFreshAgentSubscription(state, locator) try { const success = await manager.kill(locator) + this.retireFreshAgentSessionState(state, locator) this.clearFreshAgentCreateCachesForSession(m.sessionId) - state.freshAgentAuthorizations.delete(this.freshAgentAuthorizationKey(locator)) this.send(ws, { type: 'freshAgent.killed', sessionId: m.sessionId, diff --git a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index f5d3a940..c779a178 100644 --- a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts @@ -334,4 +334,80 @@ describe('WsHandler fresh-agent ownership', () => { await new Promise((resolve) => server.close(() => resolve())) } }) + + it('retires all same-session durable FreshOpenCode authorizations after kill', async () => { + const runtimeManager = { + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_kill_1', runtimeProvider: 'opencode' }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + kill: vi.fn().mockResolvedValue(true), + interrupt: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_kill_1' }, + })) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + sessionRef: { provider: 'opencode', sessionId: 'ses_kill_1' }, + })) + + await vi.waitFor(() => { + expect(runtimeManager.attach).toHaveBeenCalledTimes(2) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.kill', + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + })) + + await vi.waitFor(() => { + expect(runtimeManager.kill).toHaveBeenCalledWith({ + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/a', + }) + expect(messages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.killed', + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + success: true, + })) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.interrupt', + sessionId: 'ses_kill_1', + sessionType: 'freshopencode', + provider: 'opencode', + })) + + await vi.waitFor(() => { + expect(messages).toContainEqual(expect.objectContaining({ + type: 'error', + code: 'UNAUTHORIZED', + })) + }) + expect(runtimeManager.interrupt).not.toHaveBeenCalled() + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) }) diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 36fb72e8..61d419a0 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -629,6 +629,112 @@ describe('WsHandler fresh-agent routing', () => { } }) + it('does not replay stale placeholder create state after a materialized durable session is killed', async () => { + const runtimeManager = { + create: vi.fn() + .mockResolvedValueOnce({ + sessionId: 'freshopencode-req-stale-1', + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-stale-1' }, + }) + .mockResolvedValueOnce({ + sessionId: 'freshopencode-req-stale-2', + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-stale-2' }, + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue({ + sessionId: 'ses_stale_1', + sessionRef: { provider: 'opencode', sessionId: 'ses_stale_1' }, + }), + kill: vi.fn().mockResolvedValue(true), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + const createMessage = { + type: 'freshAgent.create', + requestId: 'req-stale-replay', + sessionType: 'freshopencode', + provider: 'opencode', + } + + ws.send(JSON.stringify(createMessage)) + + await vi.waitFor(() => { + expect(seenMessages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.created', + requestId: 'req-stale-replay', + sessionId: 'freshopencode-req-stale-1', + })) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + sessionId: 'freshopencode-req-stale-1', + sessionType: 'freshopencode', + provider: 'opencode', + text: 'materialize', + })) + + await vi.waitFor(() => { + expect(seenMessages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.session.materialized', + previousSessionId: 'freshopencode-req-stale-1', + sessionId: 'ses_stale_1', + sessionType: 'freshopencode', + provider: 'opencode', + })) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.kill', + sessionId: 'ses_stale_1', + sessionType: 'freshopencode', + provider: 'opencode', + })) + + await vi.waitFor(() => { + expect(runtimeManager.kill).toHaveBeenCalledWith({ + sessionId: 'ses_stale_1', + sessionType: 'freshopencode', + provider: 'opencode', + }) + }) + + ws.send(JSON.stringify(createMessage)) + + await vi.waitFor(() => { + expect(runtimeManager.create).toHaveBeenCalledTimes(2) + const created = seenMessages.filter((message) => ( + message.type === 'freshAgent.created' + && message.requestId === 'req-stale-replay' + )) + expect(created).toHaveLength(2) + expect(created[1]).toEqual(expect.objectContaining({ + requestId: 'req-stale-replay', + sessionId: 'freshopencode-req-stale-2', + sessionType: 'freshopencode', + provider: 'opencode', + runtimeProvider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-stale-2' }, + })) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('forwards provider materialization events as top-level websocket materialization', async () => { const listeners = new Map void>() const unsubscribeByKey = new Map>() From b8768b4ee2be99872c05203cb4a0aaf10d3ceacd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:20:50 -0700 Subject: [PATCH 14/27] fix: clear pending fresh-agent attaches on close --- server/ws-handler.ts | 10 +++++ .../ws-handler-fresh-agent-ownership.test.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index cc676086..a38b19ae 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -1162,6 +1162,7 @@ export class WsHandler { off() } state.codingCliSubscriptions.clear() + this.cancelPendingFreshAgentAttaches(state) this.cancelAllFreshAgentSubscriptions(state) this.flushWsErrorLogSummaries(state, 'connection_close') @@ -1296,6 +1297,13 @@ export class WsHandler { } } + private cancelPendingFreshAgentAttaches(state: ClientState): void { + for (const pending of state.pendingFreshAgentAttachByKey.values()) { + pending.active = false + } + state.pendingFreshAgentAttachByKey.clear() + } + private retireFreshAgentSessionState(state: ClientState, locator: FreshAgentLocator): void { this.cancelFreshAgentSubscription(state, locator) this.retireFreshAgentAuthorizations(state, locator) @@ -3361,6 +3369,7 @@ export class WsHandler { ...(m.sessionRef ? { sessionRef: m.sessionRef } : {}), })) .then(() => { + if (this.clientStates.get(ws) !== state) return const current = state.pendingFreshAgentAttachByKey.get(authorizationKey) if (current !== pendingEntry || !current?.active) return this.authorizeFreshAgentSession(state, locator) @@ -3375,6 +3384,7 @@ export class WsHandler { try { await attachPromise } catch (error) { + if (this.clientStates.get(ws) !== state) return log.warn({ err: error instanceof Error ? error : new Error(String(error)), ...locator, diff --git a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index c779a178..add257c5 100644 --- a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts @@ -335,6 +335,44 @@ describe('WsHandler fresh-agent ownership', () => { } }) + it('does not subscribe a pending FreshOpenCode attach after its socket closes', async () => { + const attach = createDeferred<{ sessionId: string; runtimeProvider: string }>() + const runtimeManager = { + attach: vi.fn(() => attach.promise), + subscribe: vi.fn().mockResolvedValue(() => undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_closed', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/closed', + sessionRef: { provider: 'opencode', sessionId: 'ses_closed' }, + })) + + await vi.waitFor(() => expect(runtimeManager.attach).toHaveBeenCalled()) + + const closed = new Promise((resolve) => ws.once('close', () => resolve())) + ws.close() + await closed + + attach.resolve({ sessionId: 'ses_closed', runtimeProvider: 'opencode' }) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) + + expect(runtimeManager.subscribe).not.toHaveBeenCalled() + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('retires all same-session durable FreshOpenCode authorizations after kill', async () => { const runtimeManager = { attach: vi.fn().mockResolvedValue({ sessionId: 'ses_kill_1', runtimeProvider: 'opencode' }), From 2f39b1a1a1b931ee74096c7f78e25b8d243c0c17 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:29:03 -0700 Subject: [PATCH 15/27] fix: require route before opencode ws mutation auth --- server/ws-handler.ts | 29 ++++-- .../ws-handler-fresh-agent-ownership.test.ts | 91 +++++++++++++++++++ .../server/ws-handler-fresh-agent.test.ts | 5 + 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index a38b19ae..268a24d3 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -1201,18 +1201,28 @@ export class WsHandler { return `${locator.sessionType}:${locator.provider}:${locator.sessionId}` } - private requiresFreshOpenCodeRouteAuthorization(locator: FreshAgentLocator): boolean { + private isDurableFreshOpenCodeLocator(locator: FreshAgentLocator): boolean { return locator.sessionType === 'freshopencode' && locator.provider === 'opencode' && locator.sessionId.startsWith('ses_') - && typeof locator.cwd === 'string' + } + + private hasFreshAgentRoute(locator: FreshAgentLocator): boolean { + return typeof locator.cwd === 'string' && locator.cwd.trim().length > 0 } + private requiresFreshOpenCodeRouteAuthorization(locator: FreshAgentLocator): boolean { + return this.isDurableFreshOpenCodeLocator(locator) + } + + private canAuthorizeFreshAgentSession(locator: FreshAgentLocator): boolean { + return !this.requiresFreshOpenCodeRouteAuthorization(locator) || this.hasFreshAgentRoute(locator) + } + private freshAgentAuthorizationKey(locator: FreshAgentLocator): string { if (!this.requiresFreshOpenCodeRouteAuthorization(locator)) return this.freshAgentKey(locator) - const cwd = typeof locator.cwd === 'string' && locator.cwd.trim().length > 0 ? locator.cwd : '' - return `${this.freshAgentKey(locator)}:${cwd}` + return `${this.freshAgentKey(locator)}:${this.hasFreshAgentRoute(locator) ? locator.cwd : ''}` } private freshAgentLocatorFromMessage(m: { @@ -1266,10 +1276,12 @@ export class WsHandler { } private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void { + if (!this.canAuthorizeFreshAgentSession(locator)) return state.freshAgentAuthorizations.set(this.freshAgentAuthorizationKey(locator), { ...locator }) } private isFreshAgentAuthorized(state: ClientState, locator: FreshAgentLocator): boolean { + if (!this.canAuthorizeFreshAgentSession(locator)) return false return state.freshAgentAuthorizations.has(this.freshAgentAuthorizationKey(locator)) } @@ -3364,10 +3376,11 @@ export class WsHandler { const locator = this.freshAgentLocatorFromMessage(m) const authorizationKey = this.freshAgentAuthorizationKey(locator) let pendingEntry: PendingFreshAgentAttachEntry - const attachPromise = Promise.resolve(manager.attach({ - ...locator, - ...(m.sessionRef ? { sessionRef: m.sessionRef } : {}), - })) + const attachPromise = Promise.resolve() + .then(() => manager.attach({ + ...locator, + ...(m.sessionRef ? { sessionRef: m.sessionRef } : {}), + })) .then(() => { if (this.clientStates.get(ws) !== state) return const current = state.pendingFreshAgentAttachByKey.get(authorizationKey) diff --git a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index add257c5..3eef4434 100644 --- a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts @@ -200,6 +200,97 @@ describe('WsHandler fresh-agent ownership', () => { } }) + it('keeps a no-cwd durable FreshOpenCode attach read-only', async () => { + const runtimeManager = { + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_readonly', runtimeProvider: 'opencode' }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + send: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_readonly', + sessionType: 'freshopencode', + provider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_readonly' }, + })) + + await vi.waitFor(() => { + expect(runtimeManager.attach).toHaveBeenCalledWith({ + sessionId: 'ses_readonly', + sessionType: 'freshopencode', + provider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_readonly' }, + }) + expect(runtimeManager.subscribe).toHaveBeenCalledWith({ + sessionId: 'ses_readonly', + sessionType: 'freshopencode', + provider: 'opencode', + }, expect.any(Function)) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-readonly', + sessionId: 'ses_readonly', + sessionType: 'freshopencode', + provider: 'opencode', + text: 'must not send', + })) + + await vi.waitFor(() => { + expect(messages).toContainEqual(expect.objectContaining({ + type: 'error', + code: 'UNAUTHORIZED', + requestId: 'send-readonly', + })) + }) + expect(runtimeManager.send).not.toHaveBeenCalled() + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it('reports a synchronous Fresh Agent attach failure', async () => { + const runtimeManager = { + attach: vi.fn(() => { + throw new Error('attach exploded') + }), + subscribe: vi.fn().mockResolvedValue(() => undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_sync_throw', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + sessionRef: { provider: 'opencode', sessionId: 'ses_sync_throw' }, + })) + + await vi.waitFor(() => { + expect(messages).toContainEqual(expect.objectContaining({ + type: 'error', + code: 'INTERNAL_ERROR', + message: 'attach exploded', + })) + }) + expect(runtimeManager.subscribe).not.toHaveBeenCalled() + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('waits for same-socket attach before sending a raced FreshOpenCode prompt', async () => { const attach = createDeferred<{ sessionId: string; runtimeProvider: string }>() const runtimeManager = { diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 61d419a0..fc603c38 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -659,12 +659,14 @@ describe('WsHandler fresh-agent routing', () => { ws.on('message', (data) => { seenMessages.push(JSON.parse(data.toString())) }) + const cwd = '/repo/stale-replay' const createMessage = { type: 'freshAgent.create', requestId: 'req-stale-replay', sessionType: 'freshopencode', provider: 'opencode', + cwd, } ws.send(JSON.stringify(createMessage)) @@ -682,6 +684,7 @@ describe('WsHandler fresh-agent routing', () => { sessionId: 'freshopencode-req-stale-1', sessionType: 'freshopencode', provider: 'opencode', + cwd, text: 'materialize', })) @@ -700,6 +703,7 @@ describe('WsHandler fresh-agent routing', () => { sessionId: 'ses_stale_1', sessionType: 'freshopencode', provider: 'opencode', + cwd, })) await vi.waitFor(() => { @@ -707,6 +711,7 @@ describe('WsHandler fresh-agent routing', () => { sessionId: 'ses_stale_1', sessionType: 'freshopencode', provider: 'opencode', + cwd, }) }) From 08a16dfd5e2dde7b0f14595590862a85c8ef5dfd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:32:05 -0700 Subject: [PATCH 16/27] Route freshopencode UI actions by cwd --- src/components/fresh-agent/FreshAgentView.tsx | 31 ++- src/components/panes/PaneContainer.tsx | 14 ++ .../fresh-agent/FreshAgentView.test.tsx | 194 ++++++++++++++++++ .../components/panes/PaneContainer.test.tsx | 38 ++++ 4 files changed, 276 insertions(+), 1 deletion(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 2cd349c4..bfef912d 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -190,6 +190,17 @@ function isFreshOpencodePlaceholderId(pane: FreshAgentPaneContent, sessionId: st && sessionId.startsWith('freshopencode-') } +function getFreshOpenCodeRouteCwd( + pane: FreshAgentPaneContent, + sessionCwd?: string, +): string | undefined { + if (pane.provider !== 'opencode' || pane.sessionType !== 'freshopencode') return undefined + const paneCwd = pane.initialCwd?.trim() + if (paneCwd) return paneCwd + const storedCwd = sessionCwd?.trim() + return storedCwd || undefined +} + function getFreshAgentSnapshotThreadId( pane: FreshAgentPaneContent, claudeSession: Parameters[0], @@ -472,6 +483,9 @@ export function FreshAgentView({ }) return state.freshAgent.sessions[sessionKey] }) + const freshOpenCodeRouteCwd = getFreshOpenCodeRouteCwd(paneContent, agentSession?.cwd) + const freshOpenCodeRouteCwdRef = useRef(freshOpenCodeRouteCwd) + freshOpenCodeRouteCwdRef.current = freshOpenCodeRouteCwd const refreshRequest = useAppSelector((state) => state.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null) const activeTabId = useAppSelector((state) => state.tabs.activeTabId) const activePaneId = useAppSelector((state) => state.panes.activePane[tabId]) @@ -754,11 +768,13 @@ export function FreshAgentView({ const startNewConversation = useCallback(() => { const current = paneContentRef.current if (current.sessionId) { + const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), }) } commitSnapshot(null) @@ -787,6 +803,7 @@ export function FreshAgentView({ const sendFork = useCallback((atTurnId?: string) => { const current = paneContentRef.current if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) // The freshAgent.forked broadcast is matched on createRequestId + // parentSessionId by the listener below, which repoints this pane at // the forked session. atTurnId is best-effort: providers that can't @@ -797,6 +814,7 @@ export function FreshAgentView({ sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), ...(atTurnId ? { input: { atTurnId } } : {}), }) }, [sendFreshAgentMessage]) @@ -809,11 +827,13 @@ export function FreshAgentView({ } if (command.action === 'compact') { if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) sendFreshAgentMessage({ type: 'freshAgent.compact', sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), ...(args ? { instructions: args } : {}), }) return @@ -1058,11 +1078,13 @@ export function FreshAgentView({ && typeof message.sessionId === 'string' ) { if (message.sessionId !== paneContent.sessionId) { + const cwd = getFreshOpenCodeRouteCwd(paneContent, agentSession?.cwd) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(cwd ? { cwd } : {}), }) } commitSnapshot(null) @@ -1086,7 +1108,7 @@ export function FreshAgentView({ } }) return unsubscribe - }, [commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, sendFreshAgentMessage, tabId, ws]) + }, [agentSession?.cwd, commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, sendFreshAgentMessage, tabId, ws]) useEffect(() => { if (!snapshotThreadId) return @@ -1374,6 +1396,7 @@ export function FreshAgentView({ const current = paneContentRef.current if (!current.sessionId) return const requestId = nanoid() + const routeCwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) recordPendingSendMetadata(requestId, {}) // Checkpoint the working tree before the agent acts on this message, so // "rewind code to here" on this turn restores the pre-turn state. Fire and @@ -1417,6 +1440,7 @@ export function FreshAgentView({ sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(routeCwd ? { cwd: routeCwd } : {}), text, settings: { ...(current.initialCwd ? { cwd: current.initialCwd } : {}), @@ -1456,6 +1480,7 @@ export function FreshAgentView({ if (!pendingApprovalsFromSnapshot || pendingApprovalsFromSnapshot.length === 0) return const current = paneContentRef.current if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) for (const approval of pendingApprovalsFromSnapshot) { if (approval.toolName && alwaysAllowToolsRef.current.has(approval.toolName)) { sendFreshAgentMessage({ @@ -1463,6 +1488,7 @@ export function FreshAgentView({ sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), requestId: approval.requestId, decision: { behavior: 'allow', updatedInput: {} }, }) @@ -1580,6 +1606,7 @@ export function FreshAgentView({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), }) } const respondToApproval = (requestId: string | number, allow: boolean) => { @@ -1590,6 +1617,7 @@ export function FreshAgentView({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), requestId, decision: allow ? { behavior: 'allow', updatedInput: {} } @@ -1712,6 +1740,7 @@ export function FreshAgentView({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), requestId: question.requestId, answers, }) diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 589dfbf7..78c39131 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -67,6 +67,18 @@ const EMPTY_FRESH_AGENT_PENDING_CREATES: Record const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] const EditorPane = lazy(() => withChunkErrorRecovery(import('./EditorPane'))) +function getFreshOpenCodePaneRouteCwd(content: PaneContent): string | undefined { + if ( + content.kind !== 'fresh-agent' + || content.provider !== 'opencode' + || content.sessionType !== 'freshopencode' + ) { + return undefined + } + const cwd = content.initialCwd?.trim() + return cwd || undefined +} + interface PaneContainerProps { tabId: string node: PaneNode @@ -290,11 +302,13 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const pendingSessionId = pendingCreate?.sessionId const sessionId = content.sessionId || pendingSessionId if (sessionId) { + const cwd = getFreshOpenCodePaneRouteCwd(content) ws.send({ type: 'freshAgent.kill', sessionId, sessionType: content.sessionType, provider: content.provider, + ...(cwd ? { cwd } : {}), }) } else { cancelCreate(content.createRequestId) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index dee80792..f9fa0cd7 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -329,6 +329,73 @@ describe('FreshAgentView', () => { }) }) + it('routes FreshOpenCode approval and question responses through the pane cwd', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ + status: 'running', + summary: 'OpenCode summary', + capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: true }, + pendingApprovals: [{ + requestId: 'approval-route', + toolName: 'Bash', + input: { command: 'pwd' }, + }], + pendingQuestions: [{ + requestId: 'question-route', + questions: [{ + header: 'Next step', + question: 'Continue?', + options: [{ label: 'Yes', description: 'Proceed' }], + multiSelect: false, + }], + }], + turns: [], + }) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('alert', { name: /permission request for bash/i })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /allow tool use/i })) + fireEvent.click(screen.getByRole('button', { name: 'Yes' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.approval.respond', + sessionId: 'ses_route_responses', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + requestId: 'approval-route', + decision: { behavior: 'allow', updatedInput: {} }, + }) + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.question.respond', + sessionId: 'ses_route_responses', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + requestId: 'question-route', + answers: { 'Continue?': 'Yes' }, + }) + }) + it('honors pane display overrides ahead of global fresh-agent settings', async () => { const store = createStore() store.dispatch(updateSettingsLocal({ @@ -555,6 +622,43 @@ describe('FreshAgentView', () => { expect(await screen.findByRole('button', { name: 'Stop' })).toBeEnabled() }) + it('routes FreshOpenCode interrupt through the pane cwd', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ + status: 'running', + capabilities: { send: false, interrupt: true, fork: true }, + turns: [], + }) + + render( + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Stop' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.interrupt', + sessionId: 'ses_stop_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + }) + }) + it('marks the fresh-agent body with pane and session flavor context metadata', async () => { const store = createStore() store.dispatch(initLayout({ @@ -2946,6 +3050,7 @@ describe('FreshAgentView', () => { provider: 'opencode', createRequestId: 'req-compact', sessionId: 'freshopencode-req-compact', + initialCwd: '/repo/route-aware', status: 'idle', }, })) @@ -2969,10 +3074,99 @@ describe('FreshAgentView', () => { sessionId: 'freshopencode-req-compact', sessionType: 'freshopencode', provider: 'opencode', + cwd: '/repo/route-aware', instructions: 'keep implementation notes', }) }) + it('routes FreshOpenCode new-conversation kill through the pane cwd', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-new-route', + sessionId: 'ses_new_route', + initialCwd: '/repo/route-aware', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled()) + wsMock.send.mockClear() + + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: '/new' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'ses_new_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + }) + }) + + it('routes FreshOpenCode forks through the pane cwd', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ + status: 'idle', + summary: 'OpenCode summary', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'turn-route-fork', + turnId: 'turn-route-fork', + role: 'assistant', + summary: 'Ready to fork', + items: [{ id: 'item-route-fork', kind: 'text', text: 'Ready to fork' }], + }, + ], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-fork-route', + sessionId: 'ses_fork_route', + initialCwd: '/repo/route-aware', + status: 'idle', + }, + })) + + render( + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Fork conversation from here' })) + + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.fork', + requestId: 'req-fork-route', + sessionId: 'ses_fork_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + input: { atTurnId: 'turn-route-fork' }, + }) + }) + it('lets Freshcodex settings choose model and thinking substrings verbatim from the gear popover', async () => { const store = createStore() store.dispatch(initLayout({ diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index bff6097e..d21f057a 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -894,6 +894,44 @@ describe('PaneContainer', () => { }) }) + it('sends the pane cwd when a FreshOpenCode pane is closed', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-freshopencode', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-freshopencode-close', + sessionId: 'ses_freshopencode_close', + initialCwd: '/repo/route-aware', + status: 'connected', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-freshopencode' }, + }, + ) + + renderWithStore( + , + store, + ) + + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + + expect(mockSend).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'ses_freshopencode_close', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + }) + }) + it('cancels a pending fresh-agent create when the pane closes before session creation finishes', () => { const node: PaneNode = { type: 'leaf', From 3e3e943d0f450d56700f05233d420eb233ecb536 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:47:14 -0700 Subject: [PATCH 17/27] fix: preserve freshopencode route cwd for cleanup --- .../context-menu/ContextMenuProvider.tsx | 21 ++++- src/components/fresh-agent/FreshAgentView.tsx | 35 +++----- src/components/panes/PaneContainer.tsx | 17 +--- src/lib/create-cancellation.ts | 17 ++++ src/lib/fresh-agent-ws.ts | 8 +- src/lib/fresh-opencode-route.ts | 49 ++++++++++ src/store/freshAgentSlice.ts | 4 + src/store/freshAgentTypes.ts | 1 + .../components/ContextMenuProvider.test.tsx | 90 +++++++++++++++++++ .../components/panes/PaneContainer.test.tsx | 51 +++++++++++ test/unit/client/lib/fresh-agent-ws.test.ts | 30 +++++++ 11 files changed, 286 insertions(+), 37 deletions(-) create mode 100644 src/lib/fresh-opencode-route.ts diff --git a/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index 30de2374..fad04182 100644 --- a/src/components/context-menu/ContextMenuProvider.tsx +++ b/src/components/context-menu/ContextMenuProvider.tsx @@ -40,6 +40,7 @@ import { type ReopenPaneActivity, type ReopenPaneSessionTarget, } from '@/lib/session-flavor-reopen' +import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' import { createLogger } from '@/lib/client-logger' import { ConfirmModal } from '@/components/ui/confirm-modal' import type { AppView } from '@/components/Sidebar' @@ -869,6 +870,7 @@ export function ContextMenuProvider({ tab, content, target, + freshAgentSessions: state.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS, providerSettings: state.settings.settings.freshAgent?.providers?.[target.targetSessionType], } } @@ -911,14 +913,31 @@ export function ContextMenuProvider({ return } + const resolvedCwd = latest.target.cwd ?? getFreshOpenCodeRouteCwd( + latest.content, + { + freshAgentSessions: latest.freshAgentSessions, + sessionId: latest.target.sessionId, + }, + ) + if (latest.content.kind === 'terminal' && latest.content.terminalId) { ws.send({ type: 'terminal.kill', terminalId: latest.content.terminalId }) } else if (latest.content.kind === 'fresh-agent' && latest.content.sessionId) { + const cwd = getFreshOpenCodeRouteCwd( + latest.content, + { + freshAgentSessions: latest.freshAgentSessions, + sessionId: latest.content.sessionId, + fallbackCwd: resolvedCwd, + }, + ) ws.send({ type: 'freshAgent.kill', sessionId: latest.content.sessionId, sessionType: latest.content.sessionType, provider: latest.content.provider, + ...(cwd ? { cwd } : {}), }) } @@ -928,7 +947,7 @@ export function ContextMenuProvider({ content: buildResumeContent({ sessionType: latest.target.targetSessionType, sessionId: latest.target.sessionId, - cwd: latest.target.cwd, + cwd: resolvedCwd, freshAgentProviderSettings: latest.providerSettings, }), })) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index bfef912d..9e8b84b6 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -18,6 +18,7 @@ import { consumePaneRefreshRequest, mergePaneContent, updatePaneContent } from ' import { clearPendingCreateFailure } from '@/store/freshAgentSlice' import { dismissTabGreen } from '@/store/turnCompletionAttention' import { registerFreshAgentCreate } from '@/lib/fresh-agent-ws' +import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' import { normalizeFreshAgentEffort, normalizeFreshAgentModel, @@ -190,17 +191,6 @@ function isFreshOpencodePlaceholderId(pane: FreshAgentPaneContent, sessionId: st && sessionId.startsWith('freshopencode-') } -function getFreshOpenCodeRouteCwd( - pane: FreshAgentPaneContent, - sessionCwd?: string, -): string | undefined { - if (pane.provider !== 'opencode' || pane.sessionType !== 'freshopencode') return undefined - const paneCwd = pane.initialCwd?.trim() - if (paneCwd) return paneCwd - const storedCwd = sessionCwd?.trim() - return storedCwd || undefined -} - function getFreshAgentSnapshotThreadId( pane: FreshAgentPaneContent, claudeSession: Parameters[0], @@ -483,7 +473,7 @@ export function FreshAgentView({ }) return state.freshAgent.sessions[sessionKey] }) - const freshOpenCodeRouteCwd = getFreshOpenCodeRouteCwd(paneContent, agentSession?.cwd) + const freshOpenCodeRouteCwd = getFreshOpenCodeRouteCwd(paneContent, { sessionCwd: agentSession?.cwd }) const freshOpenCodeRouteCwdRef = useRef(freshOpenCodeRouteCwd) freshOpenCodeRouteCwdRef.current = freshOpenCodeRouteCwd const refreshRequest = useAppSelector((state) => state.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null) @@ -768,7 +758,7 @@ export function FreshAgentView({ const startNewConversation = useCallback(() => { const current = paneContentRef.current if (current.sessionId) { - const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: current.sessionId, @@ -803,7 +793,7 @@ export function FreshAgentView({ const sendFork = useCallback((atTurnId?: string) => { const current = paneContentRef.current if (!current.sessionId) return - const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) // The freshAgent.forked broadcast is matched on createRequestId + // parentSessionId by the listener below, which repoints this pane at // the forked session. atTurnId is best-effort: providers that can't @@ -827,7 +817,7 @@ export function FreshAgentView({ } if (command.action === 'compact') { if (!current.sessionId) return - const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) sendFreshAgentMessage({ type: 'freshAgent.compact', sessionId: current.sessionId, @@ -854,13 +844,14 @@ export function FreshAgentView({ setLoadError(null) if (current.sessionId) { + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) sendFreshAgentMessage({ type: 'freshAgent.attach', sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, resumeSessionId: current.resumeSessionId, - cwd: current.initialCwd, + ...(cwd ? { cwd } : {}), }) setSnapshotRefreshNonce((value) => value + 1) } else if (!hidden && (current.status === 'creating' || current.status === 'starting')) { @@ -870,6 +861,7 @@ export function FreshAgentView({ provider: current.provider, resumeSessionId: current.resumeSessionId, sessionRef: current.sessionRef, + cwd: current.initialCwd, }) sendFreshAgentMessage(buildCreateMessage(current)) } @@ -935,6 +927,7 @@ export function FreshAgentView({ provider: paneContent.provider, resumeSessionId: paneContent.resumeSessionId, sessionRef: paneContent.sessionRef, + cwd: paneContent.initialCwd, }) sendFreshAgentMessage(buildCreateMessage(paneContent)) }, [ @@ -973,9 +966,9 @@ export function FreshAgentView({ sessionType: paneContent.sessionType, provider: paneContent.provider, resumeSessionId: paneContent.resumeSessionId, - cwd: paneContent.initialCwd, + ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), }) - }, [hidden, paneContent.initialCwd, paneContent.provider, paneContent.resumeSessionId, paneContent.sessionId, paneContent.sessionType]) + }, [freshOpenCodeRouteCwd, hidden, paneContent.provider, paneContent.resumeSessionId, paneContent.sessionId, paneContent.sessionType, sendFreshAgentMessage]) useEffect(() => { if (typeof ws.onMessage !== 'function') return @@ -1078,7 +1071,7 @@ export function FreshAgentView({ && typeof message.sessionId === 'string' ) { if (message.sessionId !== paneContent.sessionId) { - const cwd = getFreshOpenCodeRouteCwd(paneContent, agentSession?.cwd) + const cwd = getFreshOpenCodeRouteCwd(paneContent, { sessionCwd: agentSession?.cwd }) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: paneContent.sessionId, @@ -1396,7 +1389,7 @@ export function FreshAgentView({ const current = paneContentRef.current if (!current.sessionId) return const requestId = nanoid() - const routeCwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) + const routeCwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) recordPendingSendMetadata(requestId, {}) // Checkpoint the working tree before the agent acts on this message, so // "rewind code to here" on this turn restores the pre-turn state. Fire and @@ -1480,7 +1473,7 @@ export function FreshAgentView({ if (!pendingApprovalsFromSnapshot || pendingApprovalsFromSnapshot.length === 0) return const current = paneContentRef.current if (!current.sessionId) return - const cwd = getFreshOpenCodeRouteCwd(current, freshOpenCodeRouteCwdRef.current) + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) for (const approval of pendingApprovalsFromSnapshot) { if (approval.toolName && alwaysAllowToolsRef.current.has(approval.toolName)) { sendFreshAgentMessage({ diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 78c39131..155aa998 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -43,6 +43,7 @@ import { } from '@/store/freshAgentSlice' import { DEFAULT_FRESH_AGENT_STYLE } from '@shared/settings' import { cancelCreate } from '@/lib/create-cancellation' +import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' import type { TerminalMetaRecord } from '@/store/terminalMetaSlice' import type { ProjectGroup, CodingCliSession } from '@/store/types' @@ -67,18 +68,6 @@ const EMPTY_FRESH_AGENT_PENDING_CREATES: Record const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] const EditorPane = lazy(() => withChunkErrorRecovery(import('./EditorPane'))) -function getFreshOpenCodePaneRouteCwd(content: PaneContent): string | undefined { - if ( - content.kind !== 'fresh-agent' - || content.provider !== 'opencode' - || content.sessionType !== 'freshopencode' - ) { - return undefined - } - const cwd = content.initialCwd?.trim() - return cwd || undefined -} - interface PaneContainerProps { tabId: string node: PaneNode @@ -302,7 +291,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const pendingSessionId = pendingCreate?.sessionId const sessionId = content.sessionId || pendingSessionId if (sessionId) { - const cwd = getFreshOpenCodePaneRouteCwd(content) + const cwd = getFreshOpenCodeRouteCwd(content, { freshAgentSessions, sessionId }) ws.send({ type: 'freshAgent.kill', sessionId, @@ -326,7 +315,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp // Extension panes: V1 leaves server extensions running until freshell shutdown. // Future: stop singleton server when its last pane closes. dispatch(closePaneWithCleanup({ tabId, paneId })) - }, [dispatch, freshAgentPendingCreates, tabId, ws]) + }, [dispatch, freshAgentPendingCreates, freshAgentSessions, tabId, ws]) const handleFocus = useCallback((paneId: string) => { // Decision 1: visiting any pane of the tab (a click into it) dismisses the diff --git a/src/lib/create-cancellation.ts b/src/lib/create-cancellation.ts index 0af65a78..7597338f 100644 --- a/src/lib/create-cancellation.ts +++ b/src/lib/create-cancellation.ts @@ -1,9 +1,25 @@ const cancelledCreateRequestIds = new Set() +const createRequestRouteById = new Map() export function cancelCreate(requestId: string): void { cancelledCreateRequestIds.add(requestId) } +export function rememberCreateRoute(requestId: string, route: { cwd?: string }): void { + const cwd = route.cwd?.trim() + if (cwd) { + createRequestRouteById.set(requestId, { cwd }) + } else { + createRequestRouteById.delete(requestId) + } +} + +export function consumeCreateRoute(requestId: string): { cwd?: string } | undefined { + const route = createRequestRouteById.get(requestId) + createRequestRouteById.delete(requestId) + return route +} + export function consumeCancelledCreate(requestId: string): boolean { if (!cancelledCreateRequestIds.has(requestId)) return false cancelledCreateRequestIds.delete(requestId) @@ -12,4 +28,5 @@ export function consumeCancelledCreate(requestId: string): boolean { export function _resetCancelledCreates(): void { cancelledCreateRequestIds.clear() + createRequestRouteById.clear() } diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 02fef6bd..15d2519e 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -1,7 +1,7 @@ import type { AppDispatch } from '@/store/store' import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' import type { SessionRef } from '@shared/session-contract' -import { consumeCancelledCreate } from '@/lib/create-cancellation' +import { consumeCancelledCreate, consumeCreateRoute, rememberCreateRoute } from '@/lib/create-cancellation' import { flushPersistedLayoutNow } from '@/store/persistControl' import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice' import { @@ -87,12 +87,15 @@ export function registerFreshAgentCreate( sessionRef?: SessionRef sessionType: FreshAgentSessionType provider: FreshAgentRuntimeProvider + cwd?: string }, ): void { + rememberCreateRoute(requestId, { cwd: options.cwd }) dispatch(registerPendingCreate({ requestId, sessionType: options.sessionType, provider: options.provider, + cwd: options.cwd, expectsHistoryHydration: Boolean(options.resumeSessionId || options.sessionRef), })) dispatch(clearPendingCreateFailure({ requestId })) @@ -103,6 +106,7 @@ export function handleFreshAgentMessage(dispatch: AppDispatch, msg: Record + sessionId?: string + fallbackCwd?: string + } = {}, +): string | undefined { + if ( + content.kind !== 'fresh-agent' + || content.provider !== 'opencode' + || content.sessionType !== 'freshopencode' + ) { + return undefined + } + + const paneCwd = content.initialCwd?.trim() + if (paneCwd) return paneCwd + + const directSessionCwd = options.sessionCwd?.trim() + if (directSessionCwd) return directSessionCwd + + const freshAgentSessions = options.freshAgentSessions + if (freshAgentSessions) { + const candidateSessionIds = [ + options.sessionId, + content.sessionId, + content.sessionRef?.provider === content.provider ? content.sessionRef.sessionId : undefined, + content.resumeSessionId, + ].filter((candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0) + for (const candidateSessionId of candidateSessionIds) { + const session = freshAgentSessions[makeFreshAgentSessionKey({ + sessionType: content.sessionType, + provider: content.provider, + sessionId: candidateSessionId, + })] + const sessionCwd = session?.cwd?.trim() + if (sessionCwd) return sessionCwd + } + } + + const fallbackCwd = options.fallbackCwd?.trim() + return fallbackCwd || undefined +} diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 5df187b5..4b4174f1 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -184,6 +184,7 @@ const freshAgentSlice = createSlice({ expectsHistoryHydration: boolean sessionType?: FreshAgentSessionType provider?: FreshAgentRuntimeProvider + cwd?: string }>) { const current = state.pendingCreates[action.payload.requestId] state.pendingCreates[action.payload.requestId] = { @@ -191,6 +192,7 @@ const freshAgentSlice = createSlice({ sessionKey: current?.sessionKey, sessionType: action.payload.sessionType ?? current?.sessionType, provider: action.payload.provider ?? current?.provider, + cwd: action.payload.cwd ?? current?.cwd, expectsHistoryHydration: action.payload.expectsHistoryHydration, } }, @@ -219,6 +221,7 @@ const freshAgentSlice = createSlice({ : session.status session.historyLoaded = !expectsHistoryHydration session.awaitingDurableHistory = expectsHistoryHydration + if (pending?.cwd) session.cwd = pending.cwd session.restoreRetryCount = 0 session.restoreFailureCode = undefined session.restoreFailureMessage = undefined @@ -229,6 +232,7 @@ const freshAgentSlice = createSlice({ sessionKey: key, sessionType, provider, + cwd: pending?.cwd, expectsHistoryHydration, } }, diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index 782f7829..5fc6d75d 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -44,6 +44,7 @@ export type FreshAgentPendingCreate = { sessionKey?: string sessionType?: FreshAgentSessionType provider?: FreshAgentRuntimeProvider + cwd?: string expectsHistoryHydration: boolean } diff --git a/test/unit/client/components/ContextMenuProvider.test.tsx b/test/unit/client/components/ContextMenuProvider.test.tsx index 7be0f3c9..90a49070 100644 --- a/test/unit/client/components/ContextMenuProvider.test.tsx +++ b/test/unit/client/components/ContextMenuProvider.test.tsx @@ -11,6 +11,7 @@ import connectionReducer from '@/store/connectionSlice' import settingsReducer from '@/store/settingsSlice' import extensionsReducer from '@/store/extensionsSlice' import tabRecencyReducer from '@/store/tabRecencySlice' +import freshAgentReducer, { sessionInit } from '@/store/freshAgentSlice' import { ContextMenuProvider } from '@/components/context-menu/ContextMenuProvider' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -25,6 +26,11 @@ const defaultCliExtensions: ClientExtensionEntry[] = [ picker: { shortcut: 'X' }, cli: { supportsModel: true, supportsSandbox: true, supportsResume: true, resumeCommandTemplate: ['codex', 'resume', '{{sessionId}}'] }, }, + { + name: 'opencode', version: '1.0.0', label: 'OpenCode CLI', description: '', category: 'cli', + picker: { shortcut: 'O' }, + cli: { supportsResume: true, resumeCommandTemplate: ['opencode', 'run', '--session', '{{sessionId}}'] }, + }, ] import { ContextIds } from '@/components/context-menu/context-menu-constants' import TabBar from '@/components/TabBar' @@ -72,6 +78,7 @@ vi.mock('@/lib/clipboard', () => ({ const VALID_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' const CODEX_THREAD_ID = '019ec8c9-2b12-7001-a11d-e2e089860320' +const OPENCODE_SESSION_ID = 'ses_context_reopen' function createDeferred() { let resolve!: (value: T) => void @@ -92,6 +99,7 @@ function createTestStore(options?: { platform?: string | null }) { connection: connectionReducer, settings: settingsReducer, extensions: extensionsReducer, + freshAgent: freshAgentReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }), @@ -1106,6 +1114,88 @@ describe('ContextMenuProvider', () => { }) }) + it('reopens a recovered FreshOpenCode pane with session-state cwd and kills the old pane route', async () => { + const user = userEvent.setup() + const store = createTestStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + provider: 'opencode', + sessionType: 'freshopencode', + status: 'idle', + createRequestId: 'req-freshopencode', + sessionId: OPENCODE_SESSION_ID, + sessionRef: { + provider: 'opencode', + sessionId: OPENCODE_SESSION_ID, + }, + }, + })) + store.dispatch(sessionInit({ + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: OPENCODE_SESSION_ID, + cwd: '/repo/session-state', + })) + + render( + + {}} + onToggleSidebar={() => {}} + sidebarCollapsed={false} + > +
+
FreshOpenCode transcript body
+
+
+
, + ) + + await user.pointer({ target: screen.getByText('FreshOpenCode transcript body'), keys: '[MouseRight]' }) + await user.click(await screen.findByRole('menuitem', { name: 'Reopen as OpenCode CLI' })) + + await waitFor(() => { + expect(apiMocks.setSessionMetadata).toHaveBeenCalledWith( + 'opencode', + OPENCODE_SESSION_ID, + 'opencode', + { sessionTypeSource: 'explicit' }, + ) + }) + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: OPENCODE_SESSION_ID, + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/session-state', + }) + }) + + expect(store.getState().panes.layouts['tab-1']).toMatchObject({ + type: 'leaf', + content: { + kind: 'terminal', + mode: 'opencode', + sessionRef: { + provider: 'opencode', + sessionId: OPENCODE_SESSION_ID, + }, + initialCwd: '/repo/session-state', + }, + }) + }) + it('does not kill or replace a pane when reopen metadata persistence fails', async () => { const user = userEvent.setup() const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index d21f057a..b560960b 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -932,6 +932,57 @@ describe('PaneContainer', () => { }) }) + it('uses session-state cwd when closing a recovered FreshOpenCode pane without initialCwd', () => { + const node: PaneNode = { + type: 'leaf', + id: 'pane-freshopencode', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-freshopencode-close', + sessionId: 'ses_freshopencode_close', + status: 'connected', + }, + } + + const store = createStore( + { + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-freshopencode' }, + }, + {}, + {}, + { + sessions: { + 'freshopencode:opencode:ses_freshopencode_close': { + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'ses_freshopencode_close', + cwd: '/repo/session-state', + pendingPermissions: {}, + pendingQuestions: {}, + }, + }, + } as Partial, + ) + + renderWithStore( + , + store, + ) + + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + + expect(mockSend).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'ses_freshopencode_close', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/session-state', + }) + }) + it('cancels a pending fresh-agent create when the pane closes before session creation finishes', () => { const node: PaneNode = { type: 'leaf', diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index af4b4410..cbc83540 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -102,6 +102,36 @@ describe('fresh-agent-ws', () => { expect(store.getState().freshAgent.sessions['freshcodex:codex:thread-orphan']).toBeUndefined() }) + it('routes a cancelled late FreshOpenCode create cleanup through the original cwd', () => { + const store = createFreshAgentStore() + const ws = { send: vi.fn() } + + registerFreshAgentCreate(store.dispatch, 'req-opencode-orphan', { + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + }) + cancelCreate('req-opencode-orphan') + + const handled = handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.created', + requestId: 'req-opencode-orphan', + sessionId: 'ses_orphan', + sessionType: 'freshopencode', + provider: 'opencode', + }, ws) + + expect(handled).toBe(true) + expect(ws.send).toHaveBeenCalledWith({ + type: 'freshAgent.kill', + sessionId: 'ses_orphan', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/route-aware', + }) + expect(store.getState().freshAgent.sessions['freshopencode:opencode:ses_orphan']).toBeUndefined() + }) + it('handles freshAgent.create.failed', () => { const store = createFreshAgentStore() From a9951323426f63f35b3b4dccc24c4db81d45dbc4 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:53:38 -0700 Subject: [PATCH 18/27] fix: upgrade fresh-agent subscription routes --- server/ws-handler.ts | 13 ++- .../ws-handler-fresh-agent-ownership.test.ts | 90 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index 268a24d3..f62d9faf 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -163,6 +163,7 @@ type FreshAgentCreatedRecord = { type FreshAgentSubscriptionEntry = { active: boolean + locator: FreshAgentLocator off?: () => void pending?: Promise } @@ -1442,17 +1443,21 @@ export class WsHandler { const existing = state.freshAgentSubscriptions.get(key) if (existing) { existing.active = true + if (this.hasFreshAgentRoute(locator)) { + existing.locator = { ...locator } + } return } - const entry: FreshAgentSubscriptionEntry = { active: true } + const entry: FreshAgentSubscriptionEntry = { active: true, locator: { ...locator } } state.freshAgentSubscriptions.set(key, entry) const listener = (event: unknown) => { if (!entry.active) return - const materialized = this.freshAgentMaterializedMessage(locator, event) + const currentLocator = entry.locator + const materialized = this.freshAgentMaterializedMessage(currentLocator, event) if (materialized) { - this.materializeFreshAgentSession(ws, state, locator, { + this.materializeFreshAgentSession(ws, state, currentLocator, { previousSessionId: materialized.previousSessionId, sessionId: materialized.sessionId, sessionRef: materialized.sessionRef, @@ -1460,7 +1465,7 @@ export class WsHandler { this.safeSend(ws, materialized) return } - this.safeSend(ws, this.freshAgentEventMessage(locator, event)) + this.safeSend(ws, this.freshAgentEventMessage(currentLocator, event)) } entry.pending = Promise.resolve() diff --git a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index 3eef4434..b4da0471 100644 --- a/test/unit/server/ws-handler-fresh-agent-ownership.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts @@ -256,6 +256,96 @@ describe('WsHandler fresh-agent ownership', () => { } }) + it('keeps routed authorization when a read-only FreshOpenCode subscription later materializes', async () => { + let listener: ((event: unknown) => void) | undefined + const runtimeManager = { + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_upgrade', runtimeProvider: 'opencode' }), + subscribe: vi.fn().mockImplementation(async (_locator: unknown, next: (event: unknown) => void) => { + listener = next + return () => undefined + }), + send: vi.fn().mockResolvedValue(undefined), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const { ws, messages } = await connectAndAuth(server) + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_upgrade', + sessionType: 'freshopencode', + provider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_upgrade' }, + })) + + await vi.waitFor(() => expect(listener).toBeTypeOf('function')) + expect(runtimeManager.subscribe).toHaveBeenCalledTimes(1) + + ws.send(JSON.stringify({ + type: 'freshAgent.attach', + sessionId: 'ses_upgrade', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/upgraded', + sessionRef: { provider: 'opencode', sessionId: 'ses_upgrade' }, + })) + + await vi.waitFor(() => { + expect(runtimeManager.attach).toHaveBeenCalledWith({ + sessionId: 'ses_upgrade', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/upgraded', + sessionRef: { provider: 'opencode', sessionId: 'ses_upgrade' }, + }) + }) + expect(runtimeManager.subscribe).toHaveBeenCalledTimes(1) + + listener?.({ + type: 'freshAgent.session.materialized', + previousSessionId: 'ses_upgrade', + sessionId: 'ses_upgraded_materialized', + sessionRef: { provider: 'opencode', sessionId: 'ses_upgraded_materialized' }, + }) + + await vi.waitFor(() => { + expect(messages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.session.materialized', + previousSessionId: 'ses_upgrade', + sessionId: 'ses_upgraded_materialized', + sessionType: 'freshopencode', + provider: 'opencode', + })) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.send', + requestId: 'send-after-upgrade-materialize', + sessionId: 'ses_upgraded_materialized', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/upgraded', + text: 'still authorized', + })) + + await vi.waitFor(() => { + expect(runtimeManager.send).toHaveBeenCalledWith({ + sessionId: 'ses_upgraded_materialized', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/upgraded', + }, expect.objectContaining({ + requestId: 'send-after-upgrade-materialize', + text: 'still authorized', + })) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('reports a synchronous Fresh Agent attach failure', async () => { const runtimeManager = { attach: vi.fn(() => { From 232584bc37cbb291f570f089bb0e76f53427575f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 15:59:38 -0700 Subject: [PATCH 19/27] fix: route-gate freshopencode kills --- server/fresh-agent/runtime-manager.ts | 3 +- .../fresh-agent/runtime-manager.test.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index 91cbe1a5..318d9c72 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -247,7 +247,7 @@ export class FreshAgentRuntimeManager { } async kill(locator: FreshAgentSessionLocator): Promise { - const record = this.requireSession(locator) + const record = await this.requireOrRecoverSession(locator) try { if (record.adapter.kill) { return await record.adapter.kill(locator.sessionId) @@ -465,7 +465,6 @@ export class FreshAgentRuntimeManager { return await this.singleflightFreshOpenCodeAttach(locator, existing) } } else if (this.isDurableFreshOpenCode(locator) - && !existing.freshOpenCodeRouteCwd && !existing.freshOpenCodeProviderOwnedNoRoute) { throw new FreshAgentLostSessionError( `Fresh-agent session ${locator.sessionType}/${locator.provider}/${locator.sessionId} requires a cwd before mutation`, diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index 0a937397..e4108838 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -731,6 +731,50 @@ describe('FreshAgentRuntimeManager', () => { })).rejects.toThrow(/not tracked/i) }) + it('requires the tracked FreshOpenCode route before killing a durable session', async () => { + const opencodeAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'ses_kill_route' }), + attach: vi.fn().mockResolvedValue({ sessionId: 'ses_kill_route' }), + kill: vi.fn().mockResolvedValue(true), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + adapter: opencodeAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + await manager.attach({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + }) + + await expect(manager.kill({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + })).rejects.toThrow(/requires a cwd/i) + await expect(manager.kill({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/other', + })).rejects.toThrow(/tracked for/i) + expect(opencodeAdapter.kill).not.toHaveBeenCalled() + + await expect(manager.kill({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/safe', + })).resolves.toBe(true) + expect(opencodeAdapter.kill).toHaveBeenCalledWith('ses_kill_route') + }) + it('keeps session-type registration separate when hidden sessions share one runtime adapter', async () => { const claudeAdapter = { create: vi.fn() From 9e2fb87cc9babc98d012add4acbc3611269df424 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:07:03 -0700 Subject: [PATCH 20/27] fix: validate freshopencode attach routes --- .../fresh-agent/adapters/opencode/adapter.ts | 36 ++++++++++++----- server/fresh-agent/runtime-manager.ts | 40 +++++++++++++++---- .../opencode-serve-adapter.test.ts | 24 +++++++---- .../fresh-agent/runtime-manager.test.ts | 19 +++++++++ 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index 76b45268..933c948f 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -108,16 +108,8 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen return typeof cwd === 'string' && cwd.trim().length > 0 ? { cwd } : undefined } - async function ensureMutableRoute(state: OpencodeSessionState): Promise { - const realId = state.realSessionId - if (!realId) return - const cwd = state.cwd - if (state.providerCreatedInThisAdapter && (!cwd || cwd.trim().length === 0)) return - if (!cwd || cwd.trim().length === 0) { - throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`) - } + async function validateSessionRoute(realId: string, cwd: string): Promise { const expected = await canonicalizePath(cwd) - if (state.routeValidatedCwd === expected) return await validateCwd(cwd) const session = await serveManager.getSession(realId, { cwd }) if (typeof session?.id === 'string' && session.id !== realId) { @@ -131,7 +123,20 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen if (expected !== actual) { throw new FreshAgentLostSessionError(`OpenCode session ${realId} belongs to ${reportedDirectory}, not ${cwd}.`) } - state.routeValidatedCwd = expected + return expected + } + + async function ensureMutableRoute(state: OpencodeSessionState): Promise { + const realId = state.realSessionId + if (!realId) return + const cwd = state.cwd + if (state.providerCreatedInThisAdapter && (!cwd || cwd.trim().length === 0)) return + if (!cwd || cwd.trim().length === 0) { + throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`) + } + const expected = await canonicalizePath(cwd) + if (state.routeValidatedCwd === expected) return + state.routeValidatedCwd = await validateSessionRoute(realId, cwd) } async function reconcileStatus(state: OpencodeSessionState): Promise { @@ -407,8 +412,13 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const existing = sessions.get(locator.sessionId) if (existing) { if (locator.cwd) { + const routeValidatedCwd = await validateSessionRoute( + existing.realSessionId ?? locator.sessionId, + locator.cwd, + ) if (existing.cwd !== locator.cwd) existing.routeValidatedCwd = undefined existing.cwd = locator.cwd + existing.routeValidatedCwd = routeValidatedCwd } remember(existing) await reconcileStatus(existing) @@ -424,6 +434,9 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen status: 'idle', events: new EventEmitter(), sendQueue: Promise.resolve(), } + if (locator.cwd) { + state.routeValidatedCwd = await validateSessionRoute(locator.sessionId, locator.cwd) + } remember(state) bindServeStream(state) await reconcileStatus(state) @@ -485,8 +498,9 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen return { sessionId: child.id, sessionRef: { provider: 'opencode', sessionId: child.id } } }, - kill(sessionId) { + async kill(sessionId) { const state = requireState(sessionId) + await ensureMutableRoute(state) try { state.unsubscribeServe?.() } catch { /* ignore */ } sessions.delete(state.placeholderId) if (state.realSessionId) sessions.delete(state.realSessionId) diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index 318d9c72..d3b3914b 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -132,19 +132,43 @@ export class FreshAgentRuntimeManager { async attach(input: FreshAgentSessionLocator): Promise { const registration = this.requireRegistration(input.sessionType, input.provider) + const key = this.key(input) + const existing = this.sessions.get(key) + if (existing) { + if (existing.sessionType !== input.sessionType || existing.runtimeProvider !== registration.runtimeProvider) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${input.sessionId} is tracked as ${existing.sessionType}/${existing.runtimeProvider}, not ${input.sessionType}/${registration.runtimeProvider}`, + ) + } + const cwd = this.routeCwd(input) + if (this.isDurableFreshOpenCode({ + sessionType: input.sessionType, + provider: registration.runtimeProvider, + sessionId: input.sessionId, + }) && cwd && existing.freshOpenCodeRouteCwd && existing.freshOpenCodeRouteCwd !== cwd) { + throw new FreshAgentSessionLocatorMismatchError( + `Fresh-agent session ${input.sessionId} is tracked for ${existing.freshOpenCodeRouteCwd}, not ${cwd}`, + ) + } + } const attached = registration.adapter.attach ? await registration.adapter.attach(input) : { sessionId: input.sessionId } const sessionId = attached.sessionId + const attachedKey = this.key({ ...input, sessionId }) - this.sessions.set(this.key({ ...input, sessionId }), this.recordForSession({ - sessionType: input.sessionType, - runtimeProvider: registration.runtimeProvider, - adapter: registration.adapter, - sessionId, - cwd: input.cwd, - providerOwned: false, - })) + if (existing && attachedKey === key && !this.routeCwd(input)) { + this.sessions.set(attachedKey, existing) + } else { + this.sessions.set(attachedKey, this.recordForSession({ + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + sessionId, + cwd: input.cwd, + providerOwned: false, + })) + } return { sessionId, diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index 292c30ab..dfae69db 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -307,7 +307,7 @@ describe('OpenCode serve adapter: create + send', () => { ) }) - it('rejects recovered durable session mutation when OpenCode reports a different directory', async () => { + it('rejects recovered durable session attach when OpenCode reports a different directory', async () => { const manager = makeFakeManager() manager.getSession.mockResolvedValueOnce({ id: 'ses_wrong', @@ -321,13 +321,21 @@ describe('OpenCode serve adapter: create + send', () => { sessionType: 'freshopencode', provider: 'opencode', cwd: '/repo/safe', - })).resolves.toEqual({ - sessionId: 'ses_wrong', - sessionRef: { provider: 'opencode', sessionId: 'ses_wrong' }, + })).rejects.toThrow(/belongs to|directory/i) + expect(manager.promptAsync).not.toHaveBeenCalled() + }) + + it('rejects kill for no-cwd recovered durable sessions', async () => { + const manager = makeFakeManager() + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionId: 'ses_kill_no_cwd', + sessionType: 'freshopencode', + provider: 'opencode', }) - await expect(adapter.send?.('ses_wrong', { text: 'must not send' })).rejects.toThrow(/belongs to|directory/i) - expect(manager.promptAsync).not.toHaveBeenCalled() + await expect(adapter.kill?.('ses_kill_no_cwd')).rejects.toThrow(/cwd/i) }) it('marks recovered durable sessions running only when OpenCode status is busy or retry', async () => { @@ -582,7 +590,7 @@ describe('OpenCode serve adapter: history reads', () => { it('getSnapshot assembles HTTP messages into the normalized transcript', async () => { const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', title: 'Kimi chat', time: { updated: 12 } })) + manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', directory: '/repo/history', title: 'Kimi chat', time: { updated: 12 } })) manager.listMessages = vi.fn(async () => ({ messages, nextCursor: null })) const adapter = makeAdapter(manager) await adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', sessionId: 'ses_real_1', cwd: '/repo/history' }) @@ -637,7 +645,7 @@ describe('OpenCode serve adapter: history reads', () => { it('reports fork capability true and approvals/questions false', async () => { const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', time: { updated: 1 } })) + manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', directory: '/repo/history', time: { updated: 1 } })) manager.listMessages = vi.fn(async () => ({ messages: [], nextCursor: null })) const adapter = makeAdapter(manager) await adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', sessionId: 'ses_real_1', cwd: '/repo/history' }) diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index e4108838..a1fed14a 100644 --- a/test/unit/server/fresh-agent/runtime-manager.test.ts +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -753,6 +753,25 @@ describe('FreshAgentRuntimeManager', () => { cwd: '/repo/safe', }) + await expect(manager.attach({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + })).resolves.toEqual({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + }) + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(2) + + await expect(manager.attach({ + sessionId: 'ses_kill_route', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/other', + })).rejects.toThrow(/tracked for/i) + expect(opencodeAdapter.attach).toHaveBeenCalledTimes(2) + await expect(manager.kill({ sessionId: 'ses_kill_route', sessionType: 'freshopencode', From df4c88b0ef6781a0569f7147278cca5dbe6c446a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:12:40 -0700 Subject: [PATCH 21/27] fix: skip route proof for opencode placeholders --- .../fresh-agent/adapters/opencode/adapter.ts | 9 ++++----- .../opencode-serve-adapter.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index 933c948f..af9fed1e 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -411,14 +411,13 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async attach(locator) { const existing = sessions.get(locator.sessionId) if (existing) { - if (locator.cwd) { - const routeValidatedCwd = await validateSessionRoute( - existing.realSessionId ?? locator.sessionId, - locator.cwd, - ) + if (locator.cwd && existing.realSessionId) { + const routeValidatedCwd = await validateSessionRoute(existing.realSessionId, locator.cwd) if (existing.cwd !== locator.cwd) existing.routeValidatedCwd = undefined existing.cwd = locator.cwd existing.routeValidatedCwd = routeValidatedCwd + } else if (locator.cwd) { + existing.cwd = locator.cwd } remember(existing) await reconcileStatus(existing) diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index dfae69db..f10f1858 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -251,6 +251,26 @@ describe('OpenCode serve adapter: create + send', () => { ) }) + it('does not validate a placeholder attach before first materialization', async () => { + const manager = makeFakeManager() + const adapter = makeAdapter(manager) + + await adapter.create({ requestId: 'placeholder-attach', sessionType: 'freshopencode', provider: 'opencode', cwd: '/repo/placeholder' }) + await expect(adapter.attach?.({ + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'freshopencode-placeholder-attach', + cwd: '/repo/placeholder', + })).resolves.toEqual({ + sessionId: 'freshopencode-placeholder-attach', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-placeholder-attach' }, + }) + expect(manager.getSession).not.toHaveBeenCalled() + + await adapter.send?.('freshopencode-placeholder-attach', { text: 'materialize' }) + expect(manager.createSession).toHaveBeenCalledWith({ directory: '/repo/placeholder' }) + }) + it('keeps no-cwd recovered durable sessions readable but not sendable', async () => { const manager = makeFakeManager() manager.getSession.mockResolvedValueOnce({ From a4e18817adfb23939ee66919fb688c45a03b1611 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:20:34 -0700 Subject: [PATCH 22/27] Fix fresh agent recovery refresh scope --- src/components/fresh-agent/FreshAgentView.tsx | 162 +++++++-- .../fresh-agent/FreshAgentView.test.tsx | 333 ++++++++++++++++++ .../unit/client/store/tabRegistrySync.test.ts | 4 + 3 files changed, 471 insertions(+), 28 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 9e8b84b6..c4e5f899 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -56,6 +56,14 @@ import { FreshAgentSidebar } from './FreshAgentSidebar' const EARLY_STATES = new Set(['creating', 'starting']) const BUSY_STATES = new Set(['running', 'compacting']) +const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS = new Set([ + 'freshAgent.session.changed', + 'freshAgent.session.snapshot', + 'freshAgent.result', + 'freshAgent.permission.request', + 'freshAgent.permission.cancelled', + 'freshAgent.question.request', +]) const log = createLogger('FreshAgentView') function getSnapshotIdentity(snapshot: FreshAgentSnapshot): string | null { @@ -98,7 +106,6 @@ function localEchoLanded( return turns.some((turn) => ( turn.role === 'user' && getFreshAgentDisplayTurnKey(turn) === submittedTurnId - && freshAgentTurnText(turn).includes(needle) )) } if (pending && !pending.legacyAccepted) return false @@ -112,6 +119,17 @@ function isSnapshotInFlight(snapshot: FreshAgentSnapshot): boolean { return snapshot.status === 'running' || snapshot.status === 'compacting' } +function shouldClearStaleLocalEcho( + snapshot: FreshAgentSnapshot, + echo: LocalEcho, + pending?: PendingSendMetadata, +): boolean { + if (isSnapshotInFlight(snapshot)) return false + const accepted = Boolean(echo.submittedTurnId || pending?.submittedTurnId || pending?.legacyAccepted) + if (!accepted) return false + return !localEchoLanded(snapshot.turns, echo, pending) +} + function mergeSnapshotForDisplay( previous: FreshAgentSnapshot | null, next: FreshAgentSnapshot, @@ -249,6 +267,18 @@ function persistDurableFreshAgentFlavor(message: { }) } +function buildFreshAgentAttachMessage(content: FreshAgentPaneContent, cwd?: string) { + return { + type: 'freshAgent.attach', + sessionId: content.sessionId, + sessionType: content.sessionType, + provider: content.provider, + ...(content.resumeSessionId ? { resumeSessionId: content.resumeSessionId } : {}), + ...(content.sessionRef ? { sessionRef: content.sessionRef } : {}), + ...(cwd ? { cwd } : {}), + } as const +} + function buildLegacyRestoreContext(tab: { title?: string; createdAt?: number; updatedAt?: number } | undefined) { if (!tab) return undefined const title = typeof tab.title === 'string' && tab.title.trim().length > 0 @@ -321,6 +351,50 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } +function readMessageEventType(message: Record): string | undefined { + const event = isRecord(message.event) ? message.event : undefined + return typeof event?.type === 'string' ? event.type : undefined +} + +function isSnapshotInvalidatingFreshAgentEvent(message: Record): boolean { + if (message.type !== 'freshAgent.event') return false + const eventType = readMessageEventType(message) + return Boolean(eventType && SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS.has(eventType)) +} + +function locatorMatchesPane( + message: Record, + content: FreshAgentPaneContent, + knownCwd?: string, +): boolean { + if (typeof message.sessionType === 'string' && message.sessionType !== content.sessionType) return false + if (typeof message.provider === 'string' && message.provider !== content.provider) return false + + const event = isRecord(message.event) ? message.event : undefined + const locatorSessionId = typeof message.sessionId === 'string' + ? message.sessionId + : (typeof event?.sessionId === 'string' ? event.sessionId : undefined) + if (locatorSessionId) { + const validSessionIds = new Set() + if (content.sessionId) validSessionIds.add(content.sessionId) + if (content.resumeSessionId) validSessionIds.add(content.resumeSessionId) + if (content.sessionRef?.provider === content.provider) validSessionIds.add(content.sessionRef.sessionId) + if (!validSessionIds.has(locatorSessionId)) return false + } + + const locatorCwd = typeof message.cwd === 'string' + ? message.cwd + : (typeof event?.cwd === 'string' ? event.cwd : undefined) + if (locatorCwd) { + const validCwds = new Set() + if (content.initialCwd) validCwds.add(content.initialCwd) + if (knownCwd) validCwds.add(knownCwd) + if (!validCwds.has(locatorCwd)) return false + } + + return true +} + function readCodexReview(value: unknown): { id?: string; status?: string } | undefined { if (!isRecord(value)) return undefined return { @@ -488,6 +562,7 @@ export function FreshAgentView({ }, []) const [loadError, setLoadError] = useState(null) const [snapshotRefreshNonce, setSnapshotRefreshNonce] = useState(0) + const snapshotRefreshTimerRef = useRef(null) const [queuedMessages, setQueuedMessages] = useState([]) // Transient, self-clearing banner for action feedback (rewind, shell errors). const [notice, setNotice] = useState(null) @@ -625,6 +700,21 @@ export function FreshAgentView({ ws.send(message as never) }, [paneId, ws]) + const scheduleSnapshotRefresh = useCallback(() => { + if (snapshotRefreshTimerRef.current !== null) return + snapshotRefreshTimerRef.current = window.setTimeout(() => { + snapshotRefreshTimerRef.current = null + setSnapshotRefreshNonce((value) => value + 1) + }, 0) + }, []) + + useEffect(() => () => { + if (snapshotRefreshTimerRef.current !== null) { + window.clearTimeout(snapshotRefreshTimerRef.current) + snapshotRefreshTimerRef.current = null + } + }, []) + const recordPendingSendMetadata = useCallback((requestId: string, patch: PendingSendMetadata) => { const current = pendingSendMetadataRef.current.get(requestId) ?? {} const next: PendingSendMetadata = { ...current, ...patch } @@ -845,14 +935,7 @@ export function FreshAgentView({ if (current.sessionId) { const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) - sendFreshAgentMessage({ - type: 'freshAgent.attach', - sessionId: current.sessionId, - sessionType: current.sessionType, - provider: current.provider, - resumeSessionId: current.resumeSessionId, - ...(cwd ? { cwd } : {}), - }) + sendFreshAgentMessage(buildFreshAgentAttachMessage(current, cwd)) setSnapshotRefreshNonce((value) => value + 1) } else if (!hidden && (current.status === 'creating' || current.status === 'starting')) { createSentRef.current = true @@ -960,15 +1043,30 @@ export function FreshAgentView({ useEffect(() => { if (!paneContent.sessionId || hidden) return - sendFreshAgentMessage({ - type: 'freshAgent.attach', - sessionId: paneContent.sessionId, - sessionType: paneContent.sessionType, - provider: paneContent.provider, - resumeSessionId: paneContent.resumeSessionId, - ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), + sendFreshAgentMessage(buildFreshAgentAttachMessage(paneContent, freshOpenCodeRouteCwd)) + }, [ + freshOpenCodeRouteCwd, + hidden, + paneContent.provider, + paneContent.resumeSessionId, + paneContent.sessionId, + paneContent.sessionRef?.provider, + paneContent.sessionRef?.sessionId, + paneContent.sessionType, + sendFreshAgentMessage, + ]) + + useEffect(() => { + if (hidden || !paneContent.sessionId) return + if (typeof ws.onReconnect !== 'function') return + return ws.onReconnect(() => { + const current = paneContentRef.current + if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) + sendFreshAgentMessage(buildFreshAgentAttachMessage(current, cwd)) + scheduleSnapshotRefresh() }) - }, [freshOpenCodeRouteCwd, hidden, paneContent.provider, paneContent.resumeSessionId, paneContent.sessionId, paneContent.sessionType, sendFreshAgentMessage]) + }, [hidden, paneContent.sessionId, scheduleSnapshotRefresh, sendFreshAgentMessage, ws]) useEffect(() => { if (typeof ws.onMessage !== 'function') return @@ -1040,27 +1138,31 @@ export function FreshAgentView({ message.type === 'freshAgent.send.accepted' && typeof message.requestId === 'string' ) { + const current = paneContentRef.current + const echo = localEchoRef.current + const ownsRequest = pendingSendMetadataRef.current.has(message.requestId) + || echo?.requestId === message.requestId + if (!ownsRequest || !locatorMatchesPane(message, current, freshOpenCodeRouteCwdRef.current)) { + return + } const submittedTurnId = typeof message.submittedTurnId === 'string' ? message.submittedTurnId : undefined if (submittedTurnId) { recordPendingSendMetadata(message.requestId, { submittedTurnId }) - const echo = localEchoRef.current if (echo?.requestId === message.requestId) { setLocalEcho({ ...echo, submittedTurnId }) } } else { recordPendingSendMetadata(message.requestId, { legacyAccepted: true }) } - setSnapshotRefreshNonce((value) => value + 1) + scheduleSnapshotRefresh() } if ( - message.type === 'freshAgent.event' - && message.sessionId === paneContent.sessionId - && message.sessionType === paneContent.sessionType - && message.provider === paneContent.provider + isSnapshotInvalidatingFreshAgentEvent(message) + && locatorMatchesPane(message, paneContentRef.current, freshOpenCodeRouteCwdRef.current) ) { - setSnapshotRefreshNonce((value) => value + 1) + scheduleSnapshotRefresh() } if ( message.type === 'freshAgent.forked' @@ -1101,7 +1203,7 @@ export function FreshAgentView({ } }) return unsubscribe - }, [agentSession?.cwd, commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, sendFreshAgentMessage, tabId, ws]) + }, [agentSession?.cwd, commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, scheduleSnapshotRefresh, sendFreshAgentMessage, setLocalEcho, tabId, ws]) useEffect(() => { if (!snapshotThreadId) return @@ -1139,11 +1241,15 @@ export function FreshAgentView({ commitSnapshot(displaySnapshot) setSnapshotAutoTitleIdentity(snapshotIdentity) const echo = localEchoRef.current + const echoPendingMetadata = echo ? pendingSendMetadataRef.current.get(echo.requestId) : undefined const landedEcho = echo - ? localEchoLanded(displaySnapshot.turns, echo, pendingSendMetadataRef.current.get(echo.requestId)) + ? localEchoLanded(displaySnapshot.turns, echo, echoPendingMetadata) + : false + const staleEcho = echo + ? shouldClearStaleLocalEcho(displaySnapshot, echo, echoPendingMetadata) : false if (echo) { - if (landedEcho) setLocalEcho(null) + if (landedEcho || staleEcho) setLocalEcho(null) } const fresh = paneContentRef.current const nextStatus = (resolved.status as FreshAgentPaneContent['status']) ?? fresh.status @@ -1174,7 +1280,7 @@ export function FreshAgentView({ sessionRef: nextSessionRef, status: nextStatus, resumeSessionId: nextResumeSessionId, - pendingLocalEcho: landedEcho ? undefined : fresh.pendingLocalEcho, + pendingLocalEcho: landedEcho || staleEcho ? undefined : fresh.pendingLocalEcho, }, })) }) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index f9fa0cd7..f2ee112c 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -20,6 +20,7 @@ const CLAUDE_RESTORE_THREAD_ID = '550e8400-e29b-41d4-a716-446655440001' const wsMock = vi.hoisted(() => ({ send: vi.fn(), onMessage: vi.fn(() => () => {}), + onReconnect: vi.fn(() => () => {}), })) const apiMock = vi.hoisted(() => ({ @@ -183,7 +184,9 @@ function freshopencodeSnapshot(text: string, revision: number) { beforeEach(() => { wsMock.send.mockReset() wsMock.onMessage.mockReset() + wsMock.onReconnect.mockReset() wsMock.onMessage.mockImplementation(() => () => {}) + wsMock.onReconnect.mockImplementation(() => () => {}) window.sessionStorage.clear() window.localStorage.removeItem('fresh-agent-prompt-history:freshcodex') window.localStorage.removeItem('fresh-agent-prompt-history:freshclaude') @@ -1319,6 +1322,66 @@ describe('FreshAgentView', () => { expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1) }) + it('attaches materialized FreshOpenCode panes with durable route metadata on mount and reconnect', async () => { + const store = createStore() + let reconnectHandler: (() => void) | undefined + wsMock.onReconnect.mockImplementation((handler: () => void) => { + reconnectHandler = handler + return () => {} + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-attach-route', + sessionId: 'ses_attach_route', + sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, + resumeSessionId: 'ses_attach_route', + initialCwd: '/repo/route-aware', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.attach', + sessionId: 'ses_attach_route', + sessionType: 'freshopencode', + provider: 'opencode', + resumeSessionId: 'ses_attach_route', + sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, + cwd: '/repo/route-aware', + }) + }) + expect(reconnectHandler).toBeTypeOf('function') + + wsMock.send.mockClear() + act(() => { + reconnectHandler?.() + }) + + await waitFor(() => { + expect(wsMock.send).toHaveBeenCalledWith({ + type: 'freshAgent.attach', + sessionId: 'ses_attach_route', + sessionType: 'freshopencode', + provider: 'opencode', + resumeSessionId: 'ses_attach_route', + sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, + cwd: '/repo/route-aware', + }) + }) + }) + it('sends through fresh-agent WS actions with pane settings when available', async () => { const store = createStore() store.dispatch(initLayout({ @@ -3475,6 +3538,7 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', resumeSessionId: 'thread-refresh', + sessionRef: { provider: 'codex', sessionId: 'thread-refresh' }, }) }) await waitFor(() => { @@ -3544,6 +3608,275 @@ describe('FreshAgentView', () => { }) }) + it('coalesces owned snapshot invalidations and ignores non-owner or non-snapshot events', async () => { + const store = createStore() + let wsHandler: ((message: any) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { + wsHandler = handler + return () => {} + }) + apiMock.getFreshAgentThreadSnapshot + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_scoped_refresh', + status: 'idle', + summary: 'initial', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_scoped_refresh', + status: 'idle', + summary: 'updated', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'turn-scoped-user', + turnId: 'turn-scoped-user', + role: 'user', + summary: 'Refresh this pane', + items: [{ id: 'item-scoped-user', kind: 'text', text: 'Refresh this pane' }], + }, + ], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-scoped-refresh', + sessionId: 'ses_scoped_refresh', + sessionRef: { provider: 'opencode', sessionId: 'ses_scoped_refresh' }, + resumeSessionId: 'ses_scoped_refresh', + initialCwd: '/repo/scoped-refresh', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Refresh this pane' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) + + expect(wsHandler).toBeTypeOf('function') + act(() => { + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId: 'foreign-request', + submittedTurnId: 'foreign-turn', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/scoped-refresh', + }) + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'wrong-route-turn', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/other-pane', + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.stream', + sessionId: 'ses_scoped_refresh', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial' } }, + }, + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.status', + sessionId: 'ses_scoped_refresh', + status: 'running', + }, + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.metadata', + sessionId: 'ses_scoped_refresh', + cwd: '/repo/scoped-refresh', + }, + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_other_pane', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.changed', + sessionId: 'ses_other_pane', + }, + }) + }) + + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + + act(() => { + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'turn-scoped-user', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/scoped-refresh', + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.changed', + sessionId: 'ses_scoped_refresh', + }, + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_scoped_refresh', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.permission.request', + sessionId: 'ses_scoped_refresh', + requestId: 'permission-scoped', + tool: { name: 'Bash', input: { command: 'pwd' } }, + }, + }) + }) + + await waitFor(() => { + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + }) + }) + + it('clears stale local echo after an idle recovered snapshot without the submitted turn', async () => { + const store = createStore() + let wsHandler: ((message: any) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { + wsHandler = handler + return () => {} + }) + apiMock.getFreshAgentThreadSnapshot + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_stale_echo', + status: 'idle', + summary: 'initial', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_stale_echo', + status: 'idle', + summary: 'recovered', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'turn-existing-assistant', + turnId: 'turn-existing-assistant', + role: 'assistant', + summary: 'Recovered idle snapshot', + items: [{ id: 'item-existing-assistant', kind: 'text', text: 'Recovered idle snapshot' }], + }, + ], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-stale-echo', + sessionId: 'ses_stale_echo', + sessionRef: { provider: 'opencode', sessionId: 'ses_stale_echo' }, + resumeSessionId: 'ses_stale_echo', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Orphan prompt' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) + expect(screen.getByText('Orphan prompt')).toBeInTheDocument() + + act(() => { + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'turn-orphan-user', + sessionId: 'ses_stale_echo', + sessionType: 'freshopencode', + provider: 'opencode', + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_stale_echo', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.snapshot', + sessionId: 'ses_stale_echo', + status: 'idle', + latestTurnId: 'turn-existing-assistant', + revision: 2, + }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('Recovered idle snapshot')).toBeInTheDocument() + }) + expect(screen.queryByText('Orphan prompt')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store).pendingLocalEcho).toBeUndefined() + }) + it('normalizes obsolete Freshcodex models to the default radio option', async () => { const store = createStore() render( diff --git a/test/unit/client/store/tabRegistrySync.test.ts b/test/unit/client/store/tabRegistrySync.test.ts index deefeeb4..ceb921c6 100644 --- a/test/unit/client/store/tabRegistrySync.test.ts +++ b/test/unit/client/store/tabRegistrySync.test.ts @@ -219,6 +219,7 @@ describe('tabRegistrySync', () => { status: 'running', resumeSessionId: 'freshopencode-req-sync', sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-sync' }, + initialCwd: '/repo/sync', }, }, }, @@ -232,6 +233,7 @@ describe('tabRegistrySync', () => { provider: 'opencode', sessionId: 'freshopencode-req-sync', }) + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].panes[0].payload.initialCwd).toBe('/repo/sync') ws.sendTabsSyncPush.mockClear() state = { @@ -251,6 +253,7 @@ describe('tabRegistrySync', () => { status: 'running', resumeSessionId: 'ses_sync_1', sessionRef: { provider: 'opencode', sessionId: 'ses_sync_1' }, + initialCwd: '/repo/sync', }, }, }, @@ -264,6 +267,7 @@ describe('tabRegistrySync', () => { provider: 'opencode', sessionId: 'ses_sync_1', }) + expect(pushedPane.payload.initialCwd).toBe('/repo/sync') expect(pushedPane.payload).not.toHaveProperty('sessionId') expect(pushedPane.payload).not.toHaveProperty('resumeSessionId') From ff801eeea4e2b39a4ef8e9000c1585cbf8782d62 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:29:24 -0700 Subject: [PATCH 23/27] fix: harden fresh-agent refresh coalescing --- src/components/fresh-agent/FreshAgentView.tsx | 6 +- .../fresh-agent/FreshAgentView.test.tsx | 198 ++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index c4e5f899..e06f37dc 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -56,6 +56,7 @@ import { FreshAgentSidebar } from './FreshAgentSidebar' const EARLY_STATES = new Set(['creating', 'starting']) const BUSY_STATES = new Set(['running', 'compacting']) +const SNAPSHOT_REFRESH_COALESCE_MS = 50 const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS = new Set([ 'freshAgent.session.changed', 'freshAgent.session.snapshot', @@ -705,7 +706,7 @@ export function FreshAgentView({ snapshotRefreshTimerRef.current = window.setTimeout(() => { snapshotRefreshTimerRef.current = null setSnapshotRefreshNonce((value) => value + 1) - }, 0) + }, SNAPSHOT_REFRESH_COALESCE_MS) }, []) useEffect(() => () => { @@ -1238,6 +1239,7 @@ export function FreshAgentView({ autoTitleSentRef.current = true } const displaySnapshot = mergeSnapshotForDisplay(snapshotRef.current, resolved) + const snapshotAccepted = displaySnapshot !== snapshotRef.current commitSnapshot(displaySnapshot) setSnapshotAutoTitleIdentity(snapshotIdentity) const echo = localEchoRef.current @@ -1246,7 +1248,7 @@ export function FreshAgentView({ ? localEchoLanded(displaySnapshot.turns, echo, echoPendingMetadata) : false const staleEcho = echo - ? shouldClearStaleLocalEcho(displaySnapshot, echo, echoPendingMetadata) + ? snapshotAccepted && shouldClearStaleLocalEcho(displaySnapshot, echo, echoPendingMetadata) : false if (echo) { if (landedEcho || staleEcho) setLocalEcho(null) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index f2ee112c..449eab0a 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -3780,6 +3780,103 @@ describe('FreshAgentView', () => { }) }) + it('coalesces real async accepted and snapshot events delivered close together', async () => { + const store = createStore() + let wsHandler: ((message: any) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { + wsHandler = handler + return () => {} + }) + apiMock.getFreshAgentThreadSnapshot + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_async_coalesce', + status: 'idle', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_async_coalesce', + status: 'idle', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'turn-async-user', + turnId: 'turn-async-user', + role: 'user', + summary: 'Async burst prompt', + items: [{ id: 'item-async-user', kind: 'text', text: 'Async burst prompt' }], + }, + ], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-async-coalesce', + sessionId: 'ses_async_coalesce', + sessionRef: { provider: 'opencode', sessionId: 'ses_async_coalesce' }, + resumeSessionId: 'ses_async_coalesce', + initialCwd: '/repo/async-coalesce', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Async burst prompt' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) + + act(() => { + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'turn-async-user', + sessionId: 'ses_async_coalesce', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/repo/async-coalesce', + }) + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + act(() => { + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_async_coalesce', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.snapshot', + sessionId: 'ses_async_coalesce', + status: 'idle', + latestTurnId: 'turn-async-user', + revision: 2, + }, + }) + }) + + await waitFor(() => { + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + }) + }) + it('clears stale local echo after an idle recovered snapshot without the submitted turn', async () => { const store = createStore() let wsHandler: ((message: any) => void) | undefined @@ -3877,6 +3974,107 @@ describe('FreshAgentView', () => { expect(getFreshAgentPaneContent(store).pendingLocalEcho).toBeUndefined() }) + it('keeps local echo when an older snapshot response is ignored after send acceptance', async () => { + const store = createStore() + let wsHandler: ((message: any) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { + wsHandler = handler + return () => {} + }) + apiMock.getFreshAgentThreadSnapshot + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_older_echo', + revision: 8, + status: 'idle', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'turn-existing', + turnId: 'turn-existing', + role: 'assistant', + summary: 'Existing answer', + items: [{ id: 'item-existing', kind: 'text', text: 'Existing answer' }], + }, + ], + }) + .mockResolvedValueOnce({ + sessionType: 'freshopencode', + provider: 'opencode', + threadId: 'ses_older_echo', + revision: 7, + status: 'idle', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + createRequestId: 'req-older-echo', + sessionId: 'ses_older_echo', + sessionRef: { provider: 'opencode', sessionId: 'ses_older_echo' }, + resumeSessionId: 'ses_older_echo', + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Existing answer')).toBeInTheDocument() + }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Keep this echo' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) + expect(screen.getByText('Keep this echo')).toBeInTheDocument() + + act(() => { + wsHandler?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'turn-keep-echo', + sessionId: 'ses_older_echo', + sessionType: 'freshopencode', + provider: 'opencode', + }) + wsHandler?.({ + type: 'freshAgent.event', + sessionId: 'ses_older_echo', + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.snapshot', + sessionId: 'ses_older_echo', + status: 'idle', + latestTurnId: 'turn-existing', + revision: 7, + }, + }) + }) + + await waitFor(() => { + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + }) + expect(screen.getByText('Keep this echo')).toBeInTheDocument() + expect(getFreshAgentPaneContent(store).pendingLocalEcho).toEqual(expect.objectContaining({ + requestId, + submittedTurnId: 'turn-keep-echo', + text: 'Keep this echo', + })) + }) + it('normalizes obsolete Freshcodex models to the default radio option', async () => { const store = createStore() render( From ef9882a6e63ef717564be019936ce93c4273a155 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:34:20 -0700 Subject: [PATCH 24/27] Coalesce retention stream replacements --- server/terminal-stream/broker.ts | 13 ++- .../server/ws-handler-backpressure.test.ts | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/server/terminal-stream/broker.ts b/server/terminal-stream/broker.ts index 67114583..2c991c2b 100644 --- a/server/terminal-stream/broker.ts +++ b/server/terminal-stream/broker.ts @@ -802,7 +802,7 @@ export class TerminalStreamBroker { private appendOutputFrames(terminalId: string, data: string): ReplayFrame[] { const state = this.getOrCreateTerminalState(terminalId) - let streamId = this.streamIdentity.ensureStream(terminalId) + const streamId = this.streamIdentity.ensureStream(terminalId) const fragments = fragmentTerminalOutputForPayloadBudget({ data, maxSerializedBytes: TERMINAL_STREAM_BATCH_MAX_BYTES, @@ -818,13 +818,12 @@ export class TerminalStreamBroker { }) const frames: ReplayFrame[] = [] for (const fragment of fragments) { - const fragmentStreamId = streamId frames.push(state.replayRing.append(fragment, { streamId })) - const retainedStreamId = this.handleReplayRetentionLoss(terminalId, state, fragmentStreamId) - if (retainedStreamId) { - this.retagFrames(frames, fragmentStreamId, retainedStreamId) - streamId = retainedStreamId - } + } + + const retainedStreamId = this.handleReplayRetentionLoss(terminalId, state, streamId) + if (retainedStreamId) { + this.retagFrames(frames, streamId, retainedStreamId) } return frames } diff --git a/test/unit/server/ws-handler-backpressure.test.ts b/test/unit/server/ws-handler-backpressure.test.ts index b44372c7..5ce330fa 100644 --- a/test/unit/server/ws-handler-backpressure.test.ts +++ b/test/unit/server/ws-handler-backpressure.test.ts @@ -662,6 +662,93 @@ describe('TerminalStreamBroker catastrophic bufferedAmount handling', () => { broker.close() }) + it('coalesces retention-loss stream replacement for one raw append and replays the retained tail on the final stream', async () => { + const registry = new FakeBrokerRegistry() + registry.setReplayRingMaxBytes(18 * 1024) + const broker = new TerminalStreamBroker(registry as any, vi.fn()) + const terminalId = 'term-retention-coalesced-raw' + registry.createTerminal(terminalId) + + const ws = createMockWs() + await broker.attach(ws as any, terminalId, 'viewport_hydrate', 80, 24, 0, 'retention-live-attach') + const initialReady = ws.send.mock.calls + .map(([raw]) => (typeof raw === 'string' ? JSON.parse(raw) : raw)) + .find((payload) => payload?.type === 'terminal.attach.ready') + expect(initialReady?.streamId).toEqual(expect.any(String)) + ws.send.mockClear() + + registry.emit('terminal.output.raw', { + terminalId, + data: 'x'.repeat(MAX_REALTIME_MESSAGE_BYTES * 4), + at: Date.now(), + }) + vi.advanceTimersByTime(5) + + const livePayloads = ws.send.mock.calls + .map(([raw]) => (typeof raw === 'string' ? JSON.parse(raw) : raw)) + .filter((payload): payload is Record => !!payload && typeof payload === 'object') + const streamChanges = livePayloads.filter((payload) => payload.type === 'terminal.stream.changed') + const liveOutputs = livePayloads.filter((payload) => payload.type === 'terminal.output') + + expect(streamChanges).toEqual([ + expect.objectContaining({ + terminalId, + reason: 'retention_lost', + attachRequestId: 'retention-live-attach', + streamId: expect.any(String), + }), + ]) + const finalStreamId = streamChanges[0].streamId + expect(finalStreamId).not.toBe(initialReady.streamId) + expect(liveOutputs.length).toBeGreaterThan(1) + expect(liveOutputs.every((payload) => payload.streamId === finalStreamId)).toBe(true) + expect(liveOutputs.every((payload) => payload.streamId !== initialReady.streamId)).toBe(true) + + const wsReplay = createMockWs() + await broker.attach( + wsReplay as any, + terminalId, + 'transport_reconnect', + 80, + 24, + 0, + 'retention-replay-attach', + ) + vi.advanceTimersByTime(5) + + const replayPayloads = wsReplay.send.mock.calls + .map(([raw]) => (typeof raw === 'string' ? JSON.parse(raw) : raw)) + .filter((payload): payload is Record => !!payload && typeof payload === 'object') + const replayReady = replayPayloads.find((payload) => payload.type === 'terminal.attach.ready') + const replayGaps = replayPayloads.filter((payload) => payload.type === 'terminal.output.gap') + const replayOutputs = replayPayloads.filter((payload) => payload.type === 'terminal.output') + + expect(replayReady).toEqual(expect.objectContaining({ + terminalId, + attachRequestId: 'retention-replay-attach', + streamId: finalStreamId, + })) + expect(replayGaps).toEqual([ + expect.objectContaining({ + terminalId, + attachRequestId: 'retention-replay-attach', + streamId: finalStreamId, + reason: 'replay_window_exceeded', + }), + ]) + expect(replayOutputs.length).toBeGreaterThan(0) + expect(replayOutputs.every((payload) => payload.streamId === finalStreamId)).toBe(true) + + const streamBearingReplayPayloads = replayPayloads.filter((payload) => ( + payload.type === 'terminal.attach.ready' + || payload.type === 'terminal.output' + || payload.type === 'terminal.output.gap' + )) + expect(streamBearingReplayPayloads.every((payload) => payload.streamId !== initialReady.streamId)).toBe(true) + + broker.close() + }) + it('emits one aggregate terminal.replay.retention log for multiple attached clients', async () => { const registry = new FakeBrokerRegistry() registry.setReplayRingMaxBytes(6) From d4258260138d0382d183e984dea7e9214382e83f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:36:08 -0700 Subject: [PATCH 25/27] test: cover FreshOpenCode restart route recovery --- test/e2e-browser/fixtures/fake-opencode.cjs | 385 ++++++++++++++++- .../freshopencode-restart-recovery.spec.ts | 388 ++++++++++++++++++ 2 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index 7859d8d4..0d939449 100644 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -135,6 +135,16 @@ function countMessages(db, sessionId) { return Number(row?.count ?? 0) } +function sessionRow(db, sessionId) { + return db.prepare('SELECT * FROM session WHERE id = ?').get(sessionId) +} + +function sessionRowsForDirectory(db, directory) { + const rows = db.prepare('SELECT * FROM session').all() + const expected = normalizeDirectoryForComparison(directory) + return rows.filter((row) => normalizeDirectoryForComparison(row.directory) === expected) +} + function insertTextMessage(db, input) { db.prepare(` INSERT OR REPLACE INTO message (id, session_id, time_created, time_updated, data) @@ -246,6 +256,31 @@ function parseJsonText(value) { return JSON.parse(value) } +function readRequestBody(req) { + return new Promise((resolve) => { + let bodyText = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + bodyText += chunk + }) + req.on('end', () => resolve(bodyText)) + }) +} + +function normalizeDirectoryForComparison(directory) { + if (typeof directory !== 'string' || directory.length === 0) return '' + try { + return fs.realpathSync(directory) + } catch { + return path.resolve(directory) + } +} + +function routeDirectory(url) { + const value = url.searchParams.get('directory') + return typeof value === 'string' && value.length > 0 ? value : undefined +} + function readExport(sessionId) { const db = openDatabase() try { @@ -353,6 +388,7 @@ 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 requireDirectoryRoute = process.env.FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE === '1' if (!Number.isInteger(port) || port <= 0 || port > 65535) { process.stdout.write('fake opencode: no server port requested\n') @@ -388,6 +424,141 @@ process.stdin.on('data', (data) => { const eventClients = new Set() +function sendJson(res, statusCode, body, headers = {}) { + res.writeHead(statusCode, { 'content-type': 'application/json', ...headers }) + res.end(JSON.stringify(body)) +} + +function rejectRoute(res, input) { + appendAudit({ + event: 'route_rejected', + routeEvent: input.routeEvent, + method: input.method, + pathname: input.pathname, + sessionId: input.sessionId, + routeDirectory: input.routeDirectory, + expectedDirectory: input.expectedDirectory, + reason: input.reason, + }) + sendJson(res, input.statusCode ?? 409, { + error: input.reason, + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + }) + return false +} + +function validateRouteForSession(res, input) { + if (!requireDirectoryRoute) return true + if (!input.routeDirectory) { + return rejectRoute(res, { + ...input, + reason: 'missing_directory_route', + statusCode: 400, + }) + } + if (!input.expectedDirectory) return true + if (normalizeDirectoryForComparison(input.routeDirectory) !== normalizeDirectoryForComparison(input.expectedDirectory)) { + return rejectRoute(res, { + ...input, + reason: 'mismatched_directory_route', + statusCode: 409, + }) + } + return true +} + +function messagesForSession(db, sessionId, input = {}) { + let rows = db.prepare(` + SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + ORDER BY time_created DESC, id DESC + `).all(sessionId) + if (input.before) { + const beforeIndex = rows.findIndex((row) => row.id === input.before) + if (beforeIndex >= 0) rows = rows.slice(beforeIndex + 1) + } + const limit = Number.isInteger(input.limit) && input.limit > 0 ? input.limit : rows.length + const page = rows.slice(0, limit) + const nextCursor = rows.length > limit ? page[page.length - 1]?.id : undefined + return { + messages: page.reverse().map((message) => { + const partRows = db.prepare(` + SELECT id, message_id, session_id, time_created, time_updated, data + FROM part + WHERE session_id = ? AND message_id = ? + ORDER BY id ASC + `).all(sessionId, message.id) + return { + info: { + ...(parseJsonText(message.data) ?? {}), + id: message.id, + sessionID: message.session_id, + time: { created: message.time_created, updated: message.time_updated }, + }, + parts: partRows.map((part) => ({ + ...(parseJsonText(part.data) ?? {}), + id: part.id, + sessionID: part.session_id, + messageID: part.message_id, + time: { created: part.time_created, updated: part.time_updated }, + })), + } + }), + nextCursor, + } +} + +function readSessionInfo(session) { + return { + id: session.id, + directory: session.directory, + title: session.title, + parentID: session.parent_id ?? undefined, + model: parseJsonText(session.model), + time: { created: session.time_created, updated: session.time_updated }, + } +} + +function appendPromptMessages(input) { + const db = openDatabase() + try { + ensureSchema(db) + const existing = sessionRow(db, input.sessionId) + if (!existing) return undefined + const sequence = countMessages(db, input.sessionId) + 1 + const userTime = Date.now() + const assistantTime = userTime + 1 + const promptText = input.parts + .map((part) => typeof part?.text === 'string' ? part.text : '') + .filter(Boolean) + .join('\n') + const responseText = process.env.FAKE_OPENCODE_RESPONSE_TEXT || `Fake OpenCode response: ${promptText}` + const userMessageId = `${input.sessionId}_msg_${sequence}_user` + const assistantMessageId = `${input.sessionId}_msg_${sequence + 1}_assistant` + insertTextMessage(db, { + sessionId: input.sessionId, + messageId: userMessageId, + partId: `${userMessageId}_part_text`, + role: 'user', + text: promptText, + now: userTime, + }) + insertTextMessage(db, { + sessionId: input.sessionId, + messageId: assistantMessageId, + partId: `${assistantMessageId}_part_text`, + role: 'assistant', + text: responseText, + now: assistantTime, + }) + db.prepare('UPDATE session SET time_updated = ? WHERE id = ?').run(assistantTime, input.sessionId) + return { promptText, responseText, userMessageId, assistantMessageId, assistantTime } + } finally { + db.close() + } +} + function emitSessionEvents(res) { if (res.destroyed) return appendAudit({ @@ -431,7 +602,7 @@ function scheduleSessionEvents(res) { setTimeout(() => emitSessionEvents(res), 100) } -const server = http.createServer((req, res) => { +const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${hostname}:${port}`) if (url.pathname === '/global/health') { res.writeHead(200, { 'content-type': 'application/json' }) @@ -440,20 +611,57 @@ const server = http.createServer((req, res) => { } if (url.pathname === '/session/status') { + const directory = routeDirectory(url) + if (requireDirectoryRoute && !directory) { + rejectRoute(res, { + routeEvent: 'status', + method: req.method, + pathname: url.pathname, + routeDirectory: directory, + reason: 'missing_directory_route', + statusCode: 400, + }) + return + } + const statuses = {} + if (directory) { + const db = openDatabase() + try { + ensureSchema(db) + const rows = sessionRowsForDirectory(db, directory) + if (requireDirectoryRoute && rows.length === 0) { + rejectRoute(res, { + routeEvent: 'status', + method: req.method, + pathname: url.pathname, + routeDirectory: directory, + reason: 'unknown_directory_route', + statusCode: 409, + }) + return + } + for (const row of rows) { + statuses[row.id] = { type: 'idle' } + } + } finally { + db.close() + } + } else { + statuses[rootSessionId] = { type: 'idle' } + statuses[childSessionId] = { type: 'idle' } + } appendAudit({ event: 'status', rootSessionId, childSessionId, + routeDirectory: directory, + sessionIds: Object.keys(statuses), }) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ - [rootSessionId]: { type: 'busy' }, - [childSessionId]: { type: 'busy' }, - })) + sendJson(res, 200, statuses) return } - if (url.pathname === '/event') { + if (url.pathname === '/event' || url.pathname === '/global/event') { res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -468,12 +676,135 @@ const server = http.createServer((req, res) => { return } + const sessionRouteMatch = url.pathname.match(/^\/session\/([^/]+)(?:\/(.*))?$/) + if (sessionRouteMatch) { + const sessionId = decodeURIComponent(sessionRouteMatch[1]) + const action = sessionRouteMatch[2] ?? '' + const directory = routeDirectory(url) + const db = openDatabase() + let session + try { + ensureSchema(db) + session = sessionRow(db, sessionId) + } finally { + db.close() + } + if (!session) { + sendJson(res, 404, { error: 'session not found', sessionId }) + return + } + const routeEvent = action === '' + ? 'session_get' + : action === 'prompt_async' + ? 'prompt_async' + : action === 'message' + ? 'message_list' + : action.startsWith('message/') + ? 'message_get' + : action + if (!validateRouteForSession(res, { + routeEvent, + method: req.method, + pathname: url.pathname, + sessionId, + routeDirectory: directory, + expectedDirectory: session.directory, + })) { + return + } + + if (action === '' && req.method === 'GET') { + appendAudit({ + event: 'session_get', + sessionId, + routeDirectory: directory, + directory: session.directory, + }) + sendJson(res, 200, readSessionInfo(session)) + return + } + + if (action === 'prompt_async' && req.method === 'POST') { + const body = parseJsonText(await readRequestBody(req)) || {} + const parts = Array.isArray(body.parts) ? body.parts : [] + const appended = appendPromptMessages({ sessionId, parts }) + if (!appended) { + sendJson(res, 404, { error: 'session not found', sessionId }) + return + } + appendAudit({ + event: 'prompt_async', + sessionId, + routeDirectory: directory, + directory: session.directory, + prompt: appended.promptText, + }) + sendJson(res, 200, { ok: true }) + return + } + + if (action === 'message' && req.method === 'GET') { + const limitRaw = url.searchParams.get('limit') + const limit = limitRaw ? Number(limitRaw) : undefined + const before = url.searchParams.get('before') || undefined + const messageDb = openDatabase() + try { + ensureSchema(messageDb) + const page = messagesForSession(messageDb, sessionId, { limit, before }) + appendAudit({ + event: 'message_list', + sessionId, + routeDirectory: directory, + directory: session.directory, + limit, + before, + count: page.messages.length, + }) + const headers = page.nextCursor ? { 'x-next-cursor': page.nextCursor } : {} + sendJson(res, 200, page.messages, headers) + } finally { + messageDb.close() + } + return + } + + if (action.startsWith('message/') && req.method === 'GET') { + const messageId = decodeURIComponent(action.slice('message/'.length)) + const messageDb = openDatabase() + try { + ensureSchema(messageDb) + const page = messagesForSession(messageDb, sessionId) + const message = page.messages.find((candidate) => candidate.info.id === messageId) + appendAudit({ + event: 'message_get', + sessionId, + messageId, + routeDirectory: directory, + directory: session.directory, + found: Boolean(message), + }) + if (!message) { + sendJson(res, 404, { error: 'message not found', sessionId, messageId }) + return + } + sendJson(res, 200, message) + } finally { + messageDb.close() + } + return + } + + sendJson(res, 404, { error: 'not found' }) + return + } + if (url.pathname === '/session') { if (req.method === 'POST') { appendAudit({ event: 'session_create_requested', rootSessionId, childSessionId, + routeDirectory: routeDirectory(url), }) if (process.env.FAKE_OPENCODE_HANG_SESSION_CREATE === '1') { req.on('close', () => { @@ -485,16 +816,26 @@ const server = http.createServer((req, res) => { }) return } - let bodyText = '' - req.setEncoding('utf8') - req.on('data', (chunk) => { - bodyText += chunk - }) - req.on('end', () => { - const input = parseJsonText(bodyText) || {} + try { + const input = parseJsonText(await readRequestBody(req)) || {} const now = Date.now() const sessionId = `ses_http_${now}_${process.pid}` - const directory = typeof input.directory === 'string' && input.directory.length > 0 + const queryDirectory = routeDirectory(url) + if (requireDirectoryRoute && !queryDirectory) { + rejectRoute(res, { + routeEvent: 'session_create', + method: req.method, + pathname: url.pathname, + sessionId, + routeDirectory: queryDirectory, + reason: 'missing_directory_route', + statusCode: 400, + }) + return + } + const directory = typeof queryDirectory === 'string' && queryDirectory.length > 0 + ? queryDirectory + : typeof input.directory === 'string' && input.directory.length > 0 ? input.directory : serverProjectDirectory() const title = typeof input.title === 'string' && input.title.length > 0 @@ -516,9 +857,17 @@ const server = http.createServer((req, res) => { } finally { db.close() } - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ id: sessionId, directory, title })) - }) + appendAudit({ + event: 'session_created', + sessionId, + routeDirectory: queryDirectory, + directory, + title, + }) + sendJson(res, 200, { id: sessionId, directory, title }) + } catch (error) { + sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) + } return } res.writeHead(200, { 'content-type': 'application/json' }) diff --git a/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts new file mode 100644 index 00000000..1fb63ce7 --- /dev/null +++ b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts @@ -0,0 +1,388 @@ +import { expect, test, type Page } from '@playwright/test' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { openPanePicker } from '../helpers/pane-picker.js' +import { TestHarness } from '../helpers/test-harness.js' +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') + +type FakeAuditEvent = { + event?: string + pid?: number + hostname?: string + port?: number + sessionId?: string + routeDirectory?: string + expectedDirectory?: string + directory?: string + prompt?: string + reason?: string + count?: number + messageId?: string +} + +type FreshOpencodePaneState = { + sessionId?: string + resumeSessionId?: string + status?: string + initialCwd?: string + sessionRef?: { provider?: string; sessionId?: string } +} + +async function installFakeOpencode(binDir: string): Promise { + await fsp.mkdir(binDir, { recursive: true }) + const target = path.join(binDir, 'opencode') + await fsp.copyFile(fakeOpencodeSource, target) + await fsp.chmod(target, 0o755) +} + +function createSetupHome(sharedOpencodeDataDir: string) { + return async (homeDir: string): Promise => { + const xdgShare = path.join(homeDir, '.local', 'share') + const opencodeLink = path.join(xdgShare, 'opencode') + const freshellDir = path.join(homeDir, '.freshell') + await fsp.mkdir(xdgShare, { recursive: true }) + await fsp.mkdir(freshellDir, { recursive: true }) + await fsp.mkdir(sharedOpencodeDataDir, { recursive: true }) + await fsp.rm(opencodeLink, { recursive: true, force: true }).catch(() => {}) + await fsp.symlink(sharedOpencodeDataDir, opencodeLink, 'dir') + await fsp.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ + version: 1, + settings: { + codingCli: { + enabledProviders: ['opencode'], + providers: { opencode: {} }, + }, + freshAgent: { enabled: true }, + }, + }, null, 2)) + } +} + +function createServerOptions(input: { + binDir: string + auditLogPath: string + logsDir: string + sharedOpencodeDataDir: string + port?: number + token?: string +}) { + return { + ...(input.port ? { port: input.port } : {}), + ...(input.token ? { token: input.token } : {}), + setupHome: createSetupHome(input.sharedOpencodeDataDir), + env: { + PATH: `${input.binDir}${path.delimiter}${process.env.PATH ?? ''}`, + FAKE_OPENCODE_AUDIT_LOG: input.auditLogPath, + FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE: '1', + FRESHELL_LOG_DIR: input.logsDir, + }, + } +} + +async function readAuditEvents(auditLogPath: string): Promise { + try { + const text = await fsp.readFile(auditLogPath, 'utf8') + return text.split('\n').filter(Boolean).map((line) => JSON.parse(line) as FakeAuditEvent) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +async function enableFreshOpencode(page: Page): Promise { + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__ + harness?.dispatch({ + type: 'connection/setAvailableClis', + payload: { opencode: true }, + }) + harness?.dispatch({ + type: 'settings/previewServerSettingsPatch', + payload: { + codingCli: { enabledProviders: ['opencode'] }, + freshAgent: { enabled: true }, + }, + }) + }) +} + +async function createFreshopencodePane(page: Page, cwd: string): Promise { + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshopencode$/i }).click({ force: true }) + const directoryInput = page.getByLabel(/^Starting directory for Freshopencode$/i) + await expect(directoryInput).toBeVisible({ timeout: 15_000 }) + await directoryInput.fill(cwd) + await directoryInput.press('Enter') + await expect(page.locator('[data-context="fresh-agent"]').last()).toBeVisible({ timeout: 15_000 }) +} + +async function getFreshOpencodePaneState(page: Page): Promise { + return page.evaluate(() => { + const state = window.__FRESHELL_TEST_HARNESS__?.getState() + const activeTabId = state?.tabs?.activeTabId + const findFreshOpencode = (node: any): any => { + if (!node) return undefined + if (node.type === 'leaf' && node.content?.kind === 'fresh-agent' && node.content.provider === 'opencode') { + return node.content + } + if (node.type === 'split') return findFreshOpencode(node.children?.[0]) ?? findFreshOpencode(node.children?.[1]) + return undefined + } + return findFreshOpencode(state?.panes?.layouts?.[activeTabId]) ?? {} + }) +} + +async function sendFreshAgentPrompt(page: Page, prompt: string): Promise { + const textbox = page.getByRole('textbox', { name: 'Chat message input' }) + await expect(textbox).toBeVisible({ timeout: 15_000 }) + await expect(textbox).not.toBeDisabled({ timeout: 15_000 }) + await textbox.fill(prompt) + await page.getByRole('button', { name: 'Send' }).click() +} + +async function waitForMaterializedSession(page: Page): Promise { + await expect.poll(async () => getFreshOpencodePaneState(page), { timeout: 30_000 }).toMatchObject({ + sessionId: expect.stringMatching(/^ses_/), + resumeSessionId: expect.stringMatching(/^ses_/), + initialCwd: expect.any(String), + sessionRef: { + provider: 'opencode', + sessionId: expect.stringMatching(/^ses_/), + }, + }) + const state = await getFreshOpencodePaneState(page) + expect(state.sessionId).toBe(state.sessionRef?.sessionId) + return state +} + +async function waitForPromptRoute(input: { + auditLogPath: string + sessionId: string + routeDirectory: string + prompt: string + afterEventCount?: number +}): Promise { + let events: FakeAuditEvent[] = [] + await expect.poll(async () => { + events = await readAuditEvents(input.auditLogPath) + const search = events.slice(input.afterEventCount ?? 0) + return search.some((event) => + event.event === 'prompt_async' + && event.sessionId === input.sessionId + && event.routeDirectory === input.routeDirectory + && event.prompt === input.prompt + ) + }, { timeout: 30_000 }).toBe(true) + const event = events.slice(input.afterEventCount ?? 0).find((candidate) => + candidate.event === 'prompt_async' + && candidate.sessionId === input.sessionId + && candidate.routeDirectory === input.routeDirectory + && candidate.prompt === input.prompt + ) + if (!event) throw new Error(`Missing prompt audit for ${input.prompt}`) + return event +} + +async function expectAuditedRouteUse(input: { + auditLogPath: string + sessionId: string + routeDirectory: string +}): Promise { + await expect.poll(async () => { + const events = await readAuditEvents(input.auditLogPath) + const matching = (eventName: string) => events.some((event) => + event.event === eventName + && event.sessionId === input.sessionId + && event.routeDirectory === input.routeDirectory + ) + return { + sessionGet: matching('session_get'), + promptAsync: matching('prompt_async'), + messageList: matching('message_list'), + status: events.some((event) => + event.event === 'status' + && event.routeDirectory === input.routeDirectory + ), + rejected: events.some((event) => event.event === 'route_rejected'), + } + }, { timeout: 30_000 }).toEqual({ + sessionGet: true, + promptAsync: true, + messageList: true, + status: true, + rejected: false, + }) +} + +async function latestServeLaunchForPid(auditLogPath: string, pid: number): Promise { + const events = await readAuditEvents(auditLogPath) + const launch = events.findLast((event) => + event.event === 'launch' + && event.pid === pid + && typeof event.hostname === 'string' + && typeof event.port === 'number' + ) + if (!launch) throw new Error(`Missing fake OpenCode launch for pid ${pid}: ${JSON.stringify(events, null, 2)}`) + return launch +} + +async function assertBadRouteMutationIsRejected(input: { + auditLogPath: string + baseUrl: string + sessionId: string + goodCwd: string + badCwd: string +}): Promise { + const badPrompt = `bad route mutation ${Date.now()}` + const badResponse = await fetch( + `${input.baseUrl}/session/${encodeURIComponent(input.sessionId)}/prompt_async?directory=${encodeURIComponent(input.badCwd)}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ parts: [{ type: 'text', text: badPrompt }] }), + }, + ) + expect(badResponse.status).toBe(409) + + await expect.poll(async () => { + const events = await readAuditEvents(input.auditLogPath) + return events.some((event) => + event.event === 'route_rejected' + && event.sessionId === input.sessionId + && event.routeDirectory === input.badCwd + && event.expectedDirectory === input.goodCwd + && event.reason === 'mismatched_directory_route' + ) + }, { timeout: 5_000 }).toBe(true) + + const messagesResponse = await fetch( + `${input.baseUrl}/session/${encodeURIComponent(input.sessionId)}/message?directory=${encodeURIComponent(input.goodCwd)}`, + ) + expect(messagesResponse.ok).toBe(true) + const messages = await messagesResponse.json() as Array<{ info?: { id?: string }; parts?: Array<{ text?: string }> }> + expect(JSON.stringify(messages)).not.toContain(badPrompt) + + const firstMessageId = messages.find((message) => typeof message.info?.id === 'string')?.info?.id + expect(firstMessageId).toBeTruthy() + const messageResponse = await fetch( + `${input.baseUrl}/session/${encodeURIComponent(input.sessionId)}/message/${encodeURIComponent(firstMessageId!)}?directory=${encodeURIComponent(input.goodCwd)}`, + ) + expect(messageResponse.ok).toBe(true) + await expect.poll(async () => { + const events = await readAuditEvents(input.auditLogPath) + return events.some((event) => + event.event === 'message_get' + && event.sessionId === input.sessionId + && event.messageId === firstMessageId + && event.routeDirectory === input.goodCwd + ) + }, { timeout: 5_000 }).toBe(true) +} + +test.describe('Freshopencode restart recovery', () => { + test.setTimeout(180_000) + + test('resumes a route-bound serve session after TestServer restart', async ({ page }) => { + const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-freshopencode-restart-')) + 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 cwd = path.join(sharedRoot, 'project') + const badCwd = path.join(sharedRoot, 'other-project') + const firstPrompt = `freshopencode restart first ${Date.now()}` + const followUpPrompt = `freshopencode restart follow-up ${Date.now()}` + await fsp.mkdir(cwd, { recursive: true }) + await fsp.mkdir(badCwd, { recursive: true }) + await installFakeOpencode(binDir) + + const server1 = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + })) + let server2: TestServer | undefined + + try { + const info1 = await server1.start() + await page.goto(`${info1.baseUrl}/?token=${info1.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await enableFreshOpencode(page) + await createFreshopencodePane(page, cwd) + + await sendFreshAgentPrompt(page, firstPrompt) + await expect(page.getByText(`Fake OpenCode response: ${firstPrompt}`)).toBeVisible({ timeout: 30_000 }) + const beforeRestart = await waitForMaterializedSession(page) + expect(beforeRestart.initialCwd).toBe(cwd) + const sessionId = beforeRestart.sessionId! + await waitForPromptRoute({ + auditLogPath, + sessionId, + routeDirectory: cwd, + prompt: firstPrompt, + }) + await expectAuditedRouteUse({ auditLogPath, sessionId, routeDirectory: cwd }) + + await page.evaluate(() => { + window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + const eventCountBeforeRestart = (await readAuditEvents(auditLogPath)).length + + await server1.stop() + server2 = new TestServer(createServerOptions({ + binDir, + auditLogPath, + logsDir, + sharedOpencodeDataDir, + port: info1.port, + token: info1.token, + })) + await server2.start() + await harness.waitForConnection(30_000) + + await expect.poll(async () => getFreshOpencodePaneState(page), { timeout: 30_000 }).toMatchObject({ + sessionId, + resumeSessionId: sessionId, + initialCwd: cwd, + sessionRef: { + provider: 'opencode', + sessionId, + }, + }) + + await sendFreshAgentPrompt(page, followUpPrompt) + await expect(page.getByText(`Fake OpenCode response: ${followUpPrompt}`)).toBeVisible({ timeout: 30_000 }) + const followUpAudit = await waitForPromptRoute({ + auditLogPath, + sessionId, + routeDirectory: cwd, + prompt: followUpPrompt, + afterEventCount: eventCountBeforeRestart, + }) + await expectAuditedRouteUse({ auditLogPath, sessionId, routeDirectory: cwd }) + + const launch = await latestServeLaunchForPid(auditLogPath, followUpAudit.pid!) + const sidecarBaseUrl = `http://${launch.hostname}:${launch.port}` + await assertBadRouteMutationIsRejected({ + auditLogPath, + baseUrl: sidecarBaseUrl, + sessionId, + goodCwd: cwd, + badCwd, + }) + } finally { + await server2?.stop().catch(() => {}) + await server1.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) +}) From 2e4a5428097cfc146b4577894c048c71c382f4cb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 16:48:08 -0700 Subject: [PATCH 26/27] test: harden FreshOpenCode restart smoke --- test/e2e-browser/fixtures/fake-opencode.cjs | 92 ++++++++++++++++++- .../freshopencode-restart-recovery.spec.ts | 79 +++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index 0d939449..491f620e 100644 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -423,12 +423,60 @@ process.stdin.on('data', (data) => { }) const eventClients = new Set() +const sessionStatuses = new Map() function sendJson(res, statusCode, body, headers = {}) { res.writeHead(statusCode, { 'content-type': 'application/json', ...headers }) res.end(JSON.stringify(body)) } +function currentSessionStatus(sessionId) { + return sessionStatuses.get(sessionId) || 'idle' +} + +function broadcastServeEvent(payload) { + const frame = `data: ${JSON.stringify(payload)}\n\n` + for (const client of Array.from(eventClients)) { + if (client.destroyed) { + eventClients.delete(client) + continue + } + client.write(frame) + } +} + +function emitSessionStatus(sessionId, statusType, extra = {}) { + sessionStatuses.set(sessionId, statusType) + appendAudit({ + event: 'session_status_emitted', + sessionId, + status: statusType, + ...extra, + }) + broadcastServeEvent({ + type: 'session.status', + properties: { + sessionID: sessionId, + status: { type: statusType }, + }, + }) +} + +function emitSessionIdle(sessionId, extra = {}) { + sessionStatuses.set(sessionId, 'idle') + appendAudit({ + event: 'session_idle_emitted', + sessionId, + ...extra, + }) + broadcastServeEvent({ + type: 'session.idle', + properties: { + sessionID: sessionId, + }, + }) +} + function rejectRoute(res, input) { appendAudit({ event: 'route_rejected', @@ -438,6 +486,7 @@ function rejectRoute(res, input) { sessionId: input.sessionId, routeDirectory: input.routeDirectory, expectedDirectory: input.expectedDirectory, + bodyDirectory: input.bodyDirectory, reason: input.reason, }) sendJson(res, input.statusCode ?? 409, { @@ -641,14 +690,14 @@ const server = http.createServer(async (req, res) => { return } for (const row of rows) { - statuses[row.id] = { type: 'idle' } + statuses[row.id] = { type: currentSessionStatus(row.id) } } } finally { db.close() } } else { - statuses[rootSessionId] = { type: 'idle' } - statuses[childSessionId] = { type: 'idle' } + statuses[rootSessionId] = { type: currentSessionStatus(rootSessionId) } + statuses[childSessionId] = { type: currentSessionStatus(childSessionId) } } appendAudit({ event: 'status', @@ -727,8 +776,17 @@ const server = http.createServer(async (req, res) => { if (action === 'prompt_async' && req.method === 'POST') { const body = parseJsonText(await readRequestBody(req)) || {} const parts = Array.isArray(body.parts) ? body.parts : [] + emitSessionStatus(sessionId, 'busy', { + routeDirectory: directory, + directory: session.directory, + }) const appended = appendPromptMessages({ sessionId, parts }) if (!appended) { + emitSessionIdle(sessionId, { + routeDirectory: directory, + directory: session.directory, + reason: 'append_failed', + }) sendJson(res, 404, { error: 'session not found', sessionId }) return } @@ -740,6 +798,13 @@ const server = http.createServer(async (req, res) => { prompt: appended.promptText, }) sendJson(res, 200, { ok: true }) + setTimeout(() => { + emitSessionIdle(sessionId, { + routeDirectory: directory, + directory: session.directory, + prompt: appended.promptText, + }) + }, 25).unref?.() return } @@ -833,6 +898,26 @@ const server = http.createServer(async (req, res) => { }) return } + if ( + requireDirectoryRoute + && queryDirectory + && typeof input.directory === 'string' + && input.directory.length > 0 + && normalizeDirectoryForComparison(input.directory) !== normalizeDirectoryForComparison(queryDirectory) + ) { + rejectRoute(res, { + routeEvent: 'session_create', + method: req.method, + pathname: url.pathname, + sessionId, + routeDirectory: queryDirectory, + expectedDirectory: queryDirectory, + bodyDirectory: input.directory, + reason: 'mismatched_body_directory', + statusCode: 409, + }) + return + } const directory = typeof queryDirectory === 'string' && queryDirectory.length > 0 ? queryDirectory : typeof input.directory === 'string' && input.directory.length > 0 @@ -864,6 +949,7 @@ const server = http.createServer(async (req, res) => { directory, title, }) + sessionStatuses.set(sessionId, 'idle') sendJson(res, 200, { id: sessionId, directory, title }) } catch (error) { sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) diff --git a/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts index 1fb63ce7..29050a5a 100644 --- a/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts +++ b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts @@ -19,8 +19,10 @@ type FakeAuditEvent = { sessionId?: string routeDirectory?: string expectedDirectory?: string + bodyDirectory?: string directory?: string prompt?: string + status?: string reason?: string count?: number messageId?: string @@ -161,6 +163,19 @@ async function waitForMaterializedSession(page: Page): Promise { + await expect.poll(async () => { + const state = await getFreshOpencodePaneState(page) + return { + sessionId: state.sessionId, + status: state.status, + } + }, { timeout: 30_000 }).toEqual({ + sessionId, + status: 'idle', + }) +} + async function waitForPromptRoute(input: { auditLogPath: string sessionId: string @@ -193,10 +208,12 @@ async function expectAuditedRouteUse(input: { auditLogPath: string sessionId: string routeDirectory: string + afterEventCount?: number }): Promise { await expect.poll(async () => { const events = await readAuditEvents(input.auditLogPath) - const matching = (eventName: string) => events.some((event) => + const search = events.slice(input.afterEventCount ?? 0) + const matching = (eventName: string) => search.some((event) => event.event === eventName && event.sessionId === input.sessionId && event.routeDirectory === input.routeDirectory @@ -205,17 +222,30 @@ async function expectAuditedRouteUse(input: { sessionGet: matching('session_get'), promptAsync: matching('prompt_async'), messageList: matching('message_list'), - status: events.some((event) => + status: search.some((event) => event.event === 'status' && event.routeDirectory === input.routeDirectory ), - rejected: events.some((event) => event.event === 'route_rejected'), + busy: search.some((event) => + event.event === 'session_status_emitted' + && event.sessionId === input.sessionId + && event.status === 'busy' + && event.routeDirectory === input.routeDirectory + ), + idle: search.some((event) => + event.event === 'session_idle_emitted' + && event.sessionId === input.sessionId + && event.routeDirectory === input.routeDirectory + ), + rejected: search.some((event) => event.event === 'route_rejected'), } }, { timeout: 30_000 }).toEqual({ sessionGet: true, promptAsync: true, messageList: true, status: true, + busy: true, + idle: true, rejected: false, }) } @@ -285,6 +315,34 @@ async function assertBadRouteMutationIsRejected(input: { }, { timeout: 5_000 }).toBe(true) } +async function assertConflictingCreateDirectoryIsRejected(input: { + auditLogPath: string + baseUrl: string + goodCwd: string + badCwd: string +}): Promise { + const response = await fetch( + `${input.baseUrl}/session?directory=${encodeURIComponent(input.goodCwd)}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: input.badCwd, title: 'bad create route' }), + }, + ) + expect(response.status).toBe(409) + + await expect.poll(async () => { + const events = await readAuditEvents(input.auditLogPath) + return events.some((event) => + event.event === 'route_rejected' + && event.routeDirectory === input.goodCwd + && event.expectedDirectory === input.goodCwd + && event.bodyDirectory === input.badCwd + && event.reason === 'mismatched_body_directory' + ) + }, { timeout: 5_000 }).toBe(true) +} + test.describe('Freshopencode restart recovery', () => { test.setTimeout(180_000) @@ -324,6 +382,7 @@ test.describe('Freshopencode restart recovery', () => { const beforeRestart = await waitForMaterializedSession(page) expect(beforeRestart.initialCwd).toBe(cwd) const sessionId = beforeRestart.sessionId! + await waitForSettledPane(page, sessionId) await waitForPromptRoute({ auditLogPath, sessionId, @@ -368,7 +427,13 @@ test.describe('Freshopencode restart recovery', () => { prompt: followUpPrompt, afterEventCount: eventCountBeforeRestart, }) - await expectAuditedRouteUse({ auditLogPath, sessionId, routeDirectory: cwd }) + await waitForSettledPane(page, sessionId) + await expectAuditedRouteUse({ + auditLogPath, + sessionId, + routeDirectory: cwd, + afterEventCount: eventCountBeforeRestart, + }) const launch = await latestServeLaunchForPid(auditLogPath, followUpAudit.pid!) const sidecarBaseUrl = `http://${launch.hostname}:${launch.port}` @@ -379,6 +444,12 @@ test.describe('Freshopencode restart recovery', () => { goodCwd: cwd, badCwd, }) + await assertConflictingCreateDirectoryIsRejected({ + auditLogPath, + baseUrl: sidecarBaseUrl, + goodCwd: cwd, + badCwd, + }) } finally { await server2?.stop().catch(() => {}) await server1.stop().catch(() => {}) From 69ffb2c0de4d2314743501dc756fe746fb0ccd8f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 17:04:24 -0700 Subject: [PATCH 27/27] fix: close FreshOpenCode recovery proof gaps --- .../fresh-agent/adapters/opencode/adapter.ts | 2 +- server/ws-handler.ts | 12 +++++++---- shared/ws-protocol.ts | 2 +- .../freshopencode-restart-recovery.spec.ts | 15 +++++++++++++ .../opencode-serve-adapter.test.ts | 21 +++++++++++++++++++ ...ndler-fresh-agent-lifecycle-parity.test.ts | 11 ++++++++-- .../server/ws-handler-fresh-agent.test.ts | 10 +++++++++ 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index af9fed1e..1e5edf17 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -461,7 +461,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen async interrupt(sessionId) { const state = requireState(sessionId) - await abortForState(state).catch((err) => log.warn({ err }, 'abort failed')) + await abortForState(state) emitStatus(state, 'idle') }, diff --git a/server/ws-handler.ts b/server/ws-handler.ts index f62d9faf..e7da28f1 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -1303,6 +1303,7 @@ export class WsHandler { } private retirePendingFreshAgentAttaches(state: ClientState, locator: FreshAgentLocator): void { + if (!state.pendingFreshAgentAttachByKey) return for (const [key, pending] of state.pendingFreshAgentAttachByKey.entries()) { if (!this.sameFreshAgentSession(pending, locator)) continue pending.active = false @@ -1311,6 +1312,7 @@ export class WsHandler { } private cancelPendingFreshAgentAttaches(state: ClientState): void { + if (!state.pendingFreshAgentAttachByKey) return for (const pending of state.pendingFreshAgentAttachByKey.values()) { pending.active = false } @@ -3432,8 +3434,9 @@ export class WsHandler { images: m.images, settings: m.settings, }) + let acceptedLocator = locator if (result?.sessionId && result.sessionId !== m.sessionId) { - this.materializeFreshAgentSession(ws, state, locator, { + acceptedLocator = this.materializeFreshAgentSession(ws, state, locator, { previousSessionId: m.sessionId, sessionId: result.sessionId, sessionRef: result.sessionRef, @@ -3451,9 +3454,10 @@ export class WsHandler { this.send(ws, { type: 'freshAgent.send.accepted', requestId: m.requestId, - sessionId: locator.sessionId, - sessionType: locator.sessionType, - provider: locator.provider, + sessionId: acceptedLocator.sessionId, + sessionType: acceptedLocator.sessionType, + provider: acceptedLocator.provider, + ...(acceptedLocator.cwd ? { cwd: acceptedLocator.cwd } : {}), submittedTurnId: result?.submittedTurnId, }) } diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 43752a03..08921bb1 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -874,7 +874,7 @@ export type SdkRestoreFailureCode = export type FreshAgentServerMessage = | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean } - | { type: 'freshAgent.send.accepted'; requestId: string; sessionId: string; sessionType: string; provider: string; submittedTurnId?: string } + | { type: 'freshAgent.send.accepted'; requestId: string; sessionId: string; sessionType: string; provider: string; submittedTurnId?: string; cwd?: string } | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown } | { type: 'freshAgent.session.materialized'; previousSessionId: string; sessionId: string; sessionType: string; provider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.forked'; requestId?: string; parentSessionId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } diff --git a/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts index 29050a5a..7443d53f 100644 --- a/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts +++ b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts @@ -250,6 +250,17 @@ async function expectAuditedRouteUse(input: { }) } +async function expectNoSessionCreateAfter(input: { + auditLogPath: string + afterEventCount: number +}): Promise { + const events = (await readAuditEvents(input.auditLogPath)).slice(input.afterEventCount) + expect(events.filter((event) => + event.event === 'session_create_requested' + || event.event === 'session_created' + )).toEqual([]) +} + async function latestServeLaunchForPid(auditLogPath: string, pid: number): Promise { const events = await readAuditEvents(auditLogPath) const launch = events.findLast((event) => @@ -434,6 +445,10 @@ test.describe('Freshopencode restart recovery', () => { routeDirectory: cwd, afterEventCount: eventCountBeforeRestart, }) + await expectNoSessionCreateAfter({ + auditLogPath, + afterEventCount: eventCountBeforeRestart, + }) const launch = await latestServeLaunchForPid(auditLogPath, followUpAudit.pid!) const sidecarBaseUrl = `http://${launch.hostname}:${launch.port}` diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index f10f1858..7736a675 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -689,6 +689,27 @@ describe('OpenCode serve adapter: control', () => { expect(manager.abort).toHaveBeenCalledWith('ses_real_1') }) + it('rejects interrupt for no-cwd recovered durable sessions', async () => { + const manager = makeFakeManager() + const adapter = makeAdapter(manager) + + await adapter.attach?.({ + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'ses_interrupt_no_cwd', + }) + + await expect(adapter.interrupt?.('ses_interrupt_no_cwd')).rejects.toThrow(/cwd/i) + expect(manager.abort).not.toHaveBeenCalled() + }) + + it('rejects interrupt when OpenCode abort fails', async () => { + const { manager, adapter } = await materialized() + manager.abort.mockRejectedValueOnce(new Error('abort failed upstream')) + + await expect(adapter.interrupt?.('freshopencode-req-c')).rejects.toThrow('abort failed upstream') + }) + it('compact calls the dedicated compact endpoint', async () => { const { manager, adapter } = await materialized() await adapter.compact?.('freshopencode-req-c') diff --git a/test/unit/server/ws-handler-fresh-agent-lifecycle-parity.test.ts b/test/unit/server/ws-handler-fresh-agent-lifecycle-parity.test.ts index 1929b4dd..65c74396 100644 --- a/test/unit/server/ws-handler-fresh-agent-lifecycle-parity.test.ts +++ b/test/unit/server/ws-handler-fresh-agent-lifecycle-parity.test.ts @@ -140,11 +140,18 @@ describe('WsHandler fresh-agent lifecycle parity', () => { }) }) - listeners.get(JSON.stringify({ + const createSubscriptionLocator = { sessionId: 'claude-session-parity', sessionType: 'freshclaude', provider: 'claude', - }))?.({ + cwd: '/repo', + } + + await vi.waitFor(() => { + expect(runtimeManager.subscribe).toHaveBeenCalledWith(createSubscriptionLocator, expect.any(Function)) + }) + + listeners.get(JSON.stringify(createSubscriptionLocator))?.({ type: 'sdk.session.snapshot', sessionId: 'claude-session-parity', latestTurnId: 'turn-1', diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index fc603c38..53cc0260 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -542,6 +542,7 @@ describe('WsHandler fresh-agent routing', () => { sessionId: 'codex-session-cwd-upgrade', sessionType: 'freshcodex', provider: 'codex', + cwd: '/repo/allowed', submittedTurnId: 'turn-1', })) }) @@ -599,6 +600,7 @@ describe('WsHandler fresh-agent routing', () => { ws.send(JSON.stringify({ type: 'freshAgent.send', + requestId: 'send-materialize', sessionId: 'freshopencode-req-1', sessionType: 'freshopencode', provider: 'opencode', @@ -607,6 +609,7 @@ describe('WsHandler fresh-agent routing', () => { await vi.waitFor(() => { expect(runtimeManager.send).toHaveBeenCalledWith(placeholderLocator, { + requestId: 'send-materialize', text: 'Ship it', images: undefined, settings: undefined, @@ -621,6 +624,13 @@ describe('WsHandler fresh-agent routing', () => { provider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, }) + expect(seenMessages).toContainEqual({ + type: 'freshAgent.send.accepted', + requestId: 'send-materialize', + sessionId: 'ses_real_1', + sessionType: 'freshopencode', + provider: 'opencode', + }) }) } finally { handler.close()