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 000000000..40019aed5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md @@ -0,0 +1,1136 @@ +# 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: 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. + +## 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 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 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 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. +- 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 Mutation Guard + +**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 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 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 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_recovered', + expect.objectContaining({ parts: [{ type: 'text', text: 'continue' }] }), + { cwd: '/repo/safe' }, + ) +}) + +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', + })).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(/cwd/i) + expect(manager.promptAsync).not.toHaveBeenCalled() +}) + +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() +}) +``` + +Run: `npm run test:vitest -- --run test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` +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`, extend `CreateOpencodeFreshAgentAdapterOptions`, and add state for validation: + +```ts +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 + providerCreatedInThisAdapter?: boolean + ... +} +``` + +Add a helper near `cwdRoute`: + +```ts +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 +} +``` + +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, 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` 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. + +- [ ] **Step 3: Add attach-time status reconciliation without requiring mutation 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.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 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` +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: 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** + +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() +}) + +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` +Expected: FAIL because missing sessions are never recovered. + +- [ ] **Step 2: Implement `requireOrRecoverSession`** + +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 } { + 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}`, + ) + } + 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)) { + 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, + 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) + } + } +} +``` + +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` +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 only when session id, provider, type, and cwd match. +- 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** + +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())) + } +}) +``` + +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. + +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. + +- [ ] **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`, 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 + 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. +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. + +- [ ] **Step 3: Implement pending attach authorization** + +Add to the `ClientState` type and to client-state initialization: + +```ts +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 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 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 `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. + +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.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 +} +``` + +`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. + +--- + +## 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. +- 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** + +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, +})) +``` + +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. + +- [ ] **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` for read and mutation endpoints. + +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 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. 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** + +In `server/terminal-stream/broker.ts`, change `appendOutputFrames` so fragments are appended first, and retention loss is consumed once after the loop: + +```ts +for (const fragment of fragments) { + frames.push(state.replayRing.append(fragment, { streamId })) +} +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 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. + +--- + +## 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 \ + 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" +``` + +Expected: commit succeeds with only kata `zrrj` files changed. diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index ab07fe568..1e5edf170 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,79 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen return typeof cwd === 'string' && cwd.trim().length > 0 ? { cwd } : undefined } + async function validateSessionRoute(realId: string, cwd: string): Promise { + const expected = await canonicalizePath(cwd) + 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}.`) + } + 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 { + 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 + 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) || 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') return + } catch (err) { + log.warn({ + ...logContext, + err, + reason: 'get_session_status_failed', + }, 'opencode status reconciliation failed') + } + } + async function promptAsyncForState( state: OpencodeSessionState, realId: string, @@ -119,6 +196,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 +207,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 +224,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 +294,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 +392,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,16 +404,23 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen } remember(state) bindServeStream(state) + await reconcileStatus(state) return { sessionId, sessionRef: { provider: 'opencode', sessionId } } }, async attach(locator) { const existing = sessions.get(locator.sessionId) if (existing) { - if (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) return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } } } if (isPlaceholderOpencodeSessionId(locator.sessionId) || !isRealOpencodeSessionId(locator.sessionId)) { @@ -340,8 +433,12 @@ 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) return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } } }, @@ -364,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') }, @@ -388,6 +485,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', @@ -399,8 +497,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/adapters/opencode/serve-manager.ts b/server/fresh-agent/adapters/opencode/serve-manager.ts index 00348f9cd..a2fdfadac 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/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index d8684ac10..d3b3914bb 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -90,10 +90,13 @@ type SessionRecord = { sessionType: FreshAgentSessionType runtimeProvider: FreshAgentRuntimeProvider adapter: FreshAgentRuntimeAdapter + freshOpenCodeRouteCwd?: string + freshOpenCodeProviderOwnedNoRoute?: boolean } export class FreshAgentRuntimeManager { private readonly sessions = new Map() + private readonly freshOpencodeRecoveries = new Map }>() constructor(private readonly options: FreshAgentRuntimeManagerOptions) {} @@ -103,18 +106,22 @@ 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, provider: registration.runtimeProvider, sessionId: created.sessionId, - }), { + }), this.recordForSession({ sessionType: input.sessionType, runtimeProvider: registration.runtimeProvider, adapter: registration.adapter, - }) + sessionId: created.sessionId, + cwd: input.cwd, + providerOwned: !usedResume, + })) return { sessionId: created.sessionId, sessionType: input.sessionType, @@ -125,16 +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 }), { - sessionType: input.sessionType, - runtimeProvider: registration.runtimeProvider, - adapter: registration.adapter, - }) + 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, @@ -154,11 +188,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, @@ -179,7 +216,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}`) } @@ -196,7 +233,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 @@ -211,7 +255,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 +263,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}`) } @@ -227,7 +271,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) @@ -239,7 +283,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}`) } @@ -257,13 +301,19 @@ 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: true, + })) } return forked } 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 +321,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 +427,128 @@ 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_') + && 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 { + 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) + } + } else if (this.isDurableFreshOpenCode(locator) + && !existing.freshOpenCodeProviderOwnedNoRoute) { + throw new FreshAgentLostSessionError( + `Fresh-agent session ${locator.sessionType}/${locator.provider}/${locator.sessionId} requires a cwd before mutation`, + ) + } + 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 + record.freshOpenCodeProviderOwnedNoRoute = false + 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/server/terminal-stream/broker.ts b/server/terminal-stream/broker.ts index 671145835..2c991c2bc 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/server/ws-handler.ts b/server/ws-handler.ts index f2be17984..e7da28f1a 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,15 +156,25 @@ type FreshAgentCreatedRecord = { sessionType: string provider: string runtimeProvider: string + cwd?: string sessionRef?: { provider: string; sessionId: string } + sessionLineage: string[] } type FreshAgentSubscriptionEntry = { active: boolean + locator: FreshAgentLocator off?: () => void pending?: Promise } +type FreshAgentAuthorizationEntry = FreshAgentLocator + +type PendingFreshAgentAttachEntry = FreshAgentLocator & { + active: boolean + promise: Promise +} + type WsErrorLogEntry = { code: string messageClass: string @@ -445,7 +460,8 @@ type ClientState = { codingCliSessions: Set codingCliSubscriptions: Map void> freshAgentSubscriptions: Map - freshAgentAuthorizations: Set + freshAgentAuthorizations: Map + pendingFreshAgentAttachByKey: Map wsErrorLogs: Map interestedSessions: Set sidebarOpenSessionKeys: Set @@ -970,7 +986,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) } } @@ -1096,7 +1112,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(), @@ -1146,6 +1163,7 @@ export class WsHandler { off() } state.codingCliSubscriptions.clear() + this.cancelPendingFreshAgentAttaches(state) this.cancelAllFreshAgentSubscriptions(state) this.flushWsErrorLogSummaries(state, 'connection_close') @@ -1184,6 +1202,48 @@ export class WsHandler { return `${locator.sessionType}:${locator.provider}:${locator.sessionId}` } + private isDurableFreshOpenCodeLocator(locator: FreshAgentLocator): boolean { + return locator.sessionType === 'freshopencode' + && locator.provider === 'opencode' + && locator.sessionId.startsWith('ses_') + } + + 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) + return `${this.freshAgentKey(locator)}:${this.hasFreshAgentRoute(locator) ? 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 +1277,101 @@ export class WsHandler { } private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void { - state.freshAgentAuthorizations.add(this.freshAgentKey(locator)) + if (!this.canAuthorizeFreshAgentSession(locator)) return + state.freshAgentAuthorizations.set(this.freshAgentAuthorizationKey(locator), { ...locator }) } private isFreshAgentAuthorized(state: ClientState, locator: FreshAgentLocator): boolean { - return state.freshAgentAuthorizations.has(this.freshAgentKey(locator)) + if (!this.canAuthorizeFreshAgentSession(locator)) return false + 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 { + if (!state.pendingFreshAgentAttachByKey) return + for (const [key, pending] of state.pendingFreshAgentAttachByKey.entries()) { + if (!this.sameFreshAgentSession(pending, locator)) continue + pending.active = false + state.pendingFreshAgentAttachByKey.delete(key) + } + } + + private cancelPendingFreshAgentAttaches(state: ClientState): void { + if (!state.pendingFreshAgentAttachByKey) return + 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) + 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( @@ -1237,6 +1387,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' } @@ -1275,30 +1445,29 @@ 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) { - const materializedLocator = { + this.materializeFreshAgentSession(ws, state, currentLocator, { + previousSessionId: materialized.previousSessionId, sessionId: materialized.sessionId, - sessionType: locator.sessionType, - provider: locator.provider, - } - 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 } - this.safeSend(ws, this.freshAgentEventMessage(locator, event)) + this.safeSend(ws, this.freshAgentEventMessage(currentLocator, event)) } entry.pending = Promise.resolve() @@ -3102,21 +3271,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 +3330,28 @@ export class WsHandler { sessionType: result.sessionType ?? m.sessionType, provider: runtimeProvider, runtimeProvider, + ...(m.cwd ? { cwd: m.cwd } : {}), ...(result.sessionRef ? { sessionRef: result.sessionRef } : {}), + sessionLineage: [result.sessionId], } 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 +3380,41 @@ 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) + let pendingEntry: PendingFreshAgentAttachEntry + 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) + if (current !== pendingEntry || !current?.active) return + this.authorizeFreshAgentSession(state, locator) + this.ensureFreshAgentSubscription(ws, state, locator) + }) + pendingEntry = { + ...locator, + active: true, + promise: attachPromise, } + state.pendingFreshAgentAttachByKey.set(authorizationKey, pendingEntry) try { - await Promise.resolve(manager.attach(locator)) - this.authorizeFreshAgentSession(state, locator) - this.ensureFreshAgentSubscription(ws, state, locator) + await attachPromise } catch (error) { + if (this.clientStates.get(ws) !== state) return 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 === pendingEntry) { + state.pendingFreshAgentAttachByKey.delete(authorizationKey) + } } return } @@ -3231,8 +3425,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, @@ -3240,18 +3434,13 @@ export class WsHandler { images: m.images, settings: m.settings, }) + let acceptedLocator = locator if (result?.sessionId && result.sessionId !== m.sessionId) { - this.authorizeFreshAgentSession(state, { - sessionId: result.sessionId, - sessionType: m.sessionType, - provider: m.provider, - }) - this.ensureFreshAgentSubscription(ws, state, { + acceptedLocator = this.materializeFreshAgentSession(ws, state, locator, { + previousSessionId: m.sessionId, sessionId: result.sessionId, - sessionType: m.sessionType, - provider: m.provider, + sessionRef: result.sessionRef, }) - this.cancelFreshAgentSubscription(state, locator) this.send(ws, { type: 'freshAgent.session.materialized', previousSessionId: m.sessionId, @@ -3265,6 +3454,10 @@ export class WsHandler { this.send(ws, { type: 'freshAgent.send.accepted', requestId: m.requestId, + sessionId: acceptedLocator.sessionId, + sessionType: acceptedLocator.sessionType, + provider: acceptedLocator.provider, + ...(acceptedLocator.cwd ? { cwd: acceptedLocator.cwd } : {}), submittedTurnId: result?.submittedTurnId, }) } @@ -3280,7 +3473,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 +3489,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 +3505,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 +3521,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 +3537,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 +3546,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 +3563,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 +3577,12 @@ 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.retireFreshAgentSessionState(state, locator) this.clearFreshAgentCreateCachesForSession(m.sessionId) - state.freshAgentAuthorizations.delete(this.freshAgentKey(locator)) this.send(ws, { type: 'freshAgent.killed', sessionId: m.sessionId, diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 0b7df212f..08921bb1f 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; 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/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index 30de2374d..fad041827 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 2cd349c4e..e06f37dc6 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, @@ -55,6 +56,15 @@ 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', + 'freshAgent.result', + 'freshAgent.permission.request', + 'freshAgent.permission.cancelled', + 'freshAgent.question.request', +]) const log = createLogger('FreshAgentView') function getSnapshotIdentity(snapshot: FreshAgentSnapshot): string | null { @@ -97,7 +107,6 @@ function localEchoLanded( return turns.some((turn) => ( turn.role === 'user' && getFreshAgentDisplayTurnKey(turn) === submittedTurnId - && freshAgentTurnText(turn).includes(needle) )) } if (pending && !pending.legacyAccepted) return false @@ -111,6 +120,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, @@ -248,6 +268,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 @@ -320,6 +352,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 { @@ -472,6 +548,9 @@ export function FreshAgentView({ }) return state.freshAgent.sessions[sessionKey] }) + const freshOpenCodeRouteCwd = getFreshOpenCodeRouteCwd(paneContent, { sessionCwd: 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]) @@ -484,6 +563,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) @@ -621,6 +701,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) + }, SNAPSHOT_REFRESH_COALESCE_MS) + }, []) + + 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 } @@ -754,11 +849,13 @@ export function FreshAgentView({ const startNewConversation = useCallback(() => { const current = paneContentRef.current if (current.sessionId) { + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), }) } commitSnapshot(null) @@ -787,6 +884,7 @@ export function FreshAgentView({ const sendFork = useCallback((atTurnId?: string) => { const current = paneContentRef.current if (!current.sessionId) return + 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 @@ -797,6 +895,7 @@ export function FreshAgentView({ sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), ...(atTurnId ? { input: { atTurnId } } : {}), }) }, [sendFreshAgentMessage]) @@ -809,11 +908,13 @@ export function FreshAgentView({ } if (command.action === 'compact') { if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) sendFreshAgentMessage({ type: 'freshAgent.compact', sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), ...(args ? { instructions: args } : {}), }) return @@ -834,14 +935,8 @@ export function FreshAgentView({ setLoadError(null) if (current.sessionId) { - sendFreshAgentMessage({ - type: 'freshAgent.attach', - sessionId: current.sessionId, - sessionType: current.sessionType, - provider: current.provider, - resumeSessionId: current.resumeSessionId, - cwd: current.initialCwd, - }) + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) + sendFreshAgentMessage(buildFreshAgentAttachMessage(current, cwd)) setSnapshotRefreshNonce((value) => value + 1) } else if (!hidden && (current.status === 'creating' || current.status === 'starting')) { createSentRef.current = true @@ -850,6 +945,7 @@ export function FreshAgentView({ provider: current.provider, resumeSessionId: current.resumeSessionId, sessionRef: current.sessionRef, + cwd: current.initialCwd, }) sendFreshAgentMessage(buildCreateMessage(current)) } @@ -915,6 +1011,7 @@ export function FreshAgentView({ provider: paneContent.provider, resumeSessionId: paneContent.resumeSessionId, sessionRef: paneContent.sessionRef, + cwd: paneContent.initialCwd, }) sendFreshAgentMessage(buildCreateMessage(paneContent)) }, [ @@ -947,15 +1044,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, - cwd: paneContent.initialCwd, + 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() }) - }, [hidden, paneContent.initialCwd, paneContent.provider, paneContent.resumeSessionId, paneContent.sessionId, paneContent.sessionType]) + }, [hidden, paneContent.sessionId, scheduleSnapshotRefresh, sendFreshAgentMessage, ws]) useEffect(() => { if (typeof ws.onMessage !== 'function') return @@ -1027,27 +1139,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' @@ -1058,11 +1174,13 @@ export function FreshAgentView({ && typeof message.sessionId === 'string' ) { if (message.sessionId !== paneContent.sessionId) { + const cwd = getFreshOpenCodeRouteCwd(paneContent, { sessionCwd: agentSession?.cwd }) sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(cwd ? { cwd } : {}), }) } commitSnapshot(null) @@ -1086,7 +1204,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, scheduleSnapshotRefresh, sendFreshAgentMessage, setLocalEcho, tabId, ws]) useEffect(() => { if (!snapshotThreadId) return @@ -1121,14 +1239,19 @@ 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 + 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 + ? snapshotAccepted && 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 @@ -1159,7 +1282,7 @@ export function FreshAgentView({ sessionRef: nextSessionRef, status: nextStatus, resumeSessionId: nextResumeSessionId, - pendingLocalEcho: landedEcho ? undefined : fresh.pendingLocalEcho, + pendingLocalEcho: landedEcho || staleEcho ? undefined : fresh.pendingLocalEcho, }, })) }) @@ -1374,6 +1497,7 @@ export function FreshAgentView({ const current = paneContentRef.current if (!current.sessionId) return const requestId = nanoid() + 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 @@ -1417,6 +1541,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 +1581,7 @@ export function FreshAgentView({ if (!pendingApprovalsFromSnapshot || pendingApprovalsFromSnapshot.length === 0) return const current = paneContentRef.current if (!current.sessionId) return + const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) for (const approval of pendingApprovalsFromSnapshot) { if (approval.toolName && alwaysAllowToolsRef.current.has(approval.toolName)) { sendFreshAgentMessage({ @@ -1463,6 +1589,7 @@ export function FreshAgentView({ sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, + ...(cwd ? { cwd } : {}), requestId: approval.requestId, decision: { behavior: 'allow', updatedInput: {} }, }) @@ -1580,6 +1707,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 +1718,7 @@ export function FreshAgentView({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, + ...(freshOpenCodeRouteCwd ? { cwd: freshOpenCodeRouteCwd } : {}), requestId, decision: allow ? { behavior: 'allow', updatedInput: {} } @@ -1712,6 +1841,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 589dfbf79..155aa9988 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' @@ -290,11 +291,13 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const pendingSessionId = pendingCreate?.sessionId const sessionId = content.sessionId || pendingSessionId if (sessionId) { + const cwd = getFreshOpenCodeRouteCwd(content, { freshAgentSessions, sessionId }) ws.send({ type: 'freshAgent.kill', sessionId, sessionType: content.sessionType, provider: content.provider, + ...(cwd ? { cwd } : {}), }) } else { cancelCreate(content.createRequestId) @@ -312,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 0af65a789..7597338f4 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 02fef6bda..15d2519ec 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 5df187b52..4b4174f18 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 782f7829a..5fc6d75dd 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/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index 7859d8d4c..491f620ea 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') @@ -387,6 +423,190 @@ 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', + routeEvent: input.routeEvent, + method: input.method, + pathname: input.pathname, + sessionId: input.sessionId, + routeDirectory: input.routeDirectory, + expectedDirectory: input.expectedDirectory, + bodyDirectory: input.bodyDirectory, + 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 @@ -431,7 +651,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 +660,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: currentSessionStatus(row.id) } + } + } finally { + db.close() + } + } else { + statuses[rootSessionId] = { type: currentSessionStatus(rootSessionId) } + statuses[childSessionId] = { type: currentSessionStatus(childSessionId) } + } 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 +725,151 @@ 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 : [] + 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 + } + appendAudit({ + event: 'prompt_async', + sessionId, + routeDirectory: directory, + directory: session.directory, + prompt: appended.promptText, + }) + sendJson(res, 200, { ok: true }) + setTimeout(() => { + emitSessionIdle(sessionId, { + routeDirectory: directory, + directory: session.directory, + prompt: appended.promptText, + }) + }, 25).unref?.() + 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 +881,46 @@ 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 + } + 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 ? input.directory : serverProjectDirectory() const title = typeof input.title === 'string' && input.title.length > 0 @@ -516,9 +942,18 @@ 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, + }) + sessionStatuses.set(sessionId, 'idle') + 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 000000000..7443d53f5 --- /dev/null +++ b/test/e2e-browser/specs/freshopencode-restart-recovery.spec.ts @@ -0,0 +1,474 @@ +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 + bodyDirectory?: string + directory?: string + prompt?: string + status?: 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 waitForSettledPane(page: Page, sessionId: string): 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 + 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 + afterEventCount?: number +}): Promise { + await expect.poll(async () => { + const events = await readAuditEvents(input.auditLogPath) + 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 + ) + return { + sessionGet: matching('session_get'), + promptAsync: matching('prompt_async'), + messageList: matching('message_list'), + status: search.some((event) => + event.event === 'status' + && event.routeDirectory === input.routeDirectory + ), + 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, + }) +} + +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) => + 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) +} + +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) + + 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 waitForSettledPane(page, 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 waitForSettledPane(page, sessionId) + await expectAuditedRouteUse({ + auditLogPath, + sessionId, + routeDirectory: cwd, + afterEventCount: eventCountBeforeRestart, + }) + await expectNoSessionCreateAfter({ + auditLogPath, + afterEventCount: eventCountBeforeRestart, + }) + + const launch = await latestServeLaunchForPid(auditLogPath, followUpAudit.pid!) + const sidecarBaseUrl = `http://${launch.hostname}:${launch.port}` + await assertBadRouteMutationIsRejected({ + auditLogPath, + baseUrl: sidecarBaseUrl, + sessionId, + goodCwd: cwd, + badCwd, + }) + await assertConflictingCreateDirectoryIsRejected({ + auditLogPath, + baseUrl: sidecarBaseUrl, + goodCwd: cwd, + badCwd, + }) + } finally { + await server2?.stop().catch(() => {}) + await server1.stop().catch(() => {}) + await fsp.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) +}) diff --git a/test/unit/client/components/ContextMenuProvider.test.tsx b/test/unit/client/components/ContextMenuProvider.test.tsx index 7be0f3c9b..90a490702 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/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index dee80792a..449eab0a9 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') @@ -329,6 +332,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 +625,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({ @@ -1215,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({ @@ -2946,6 +3113,7 @@ describe('FreshAgentView', () => { provider: 'opencode', createRequestId: 'req-compact', sessionId: 'freshopencode-req-compact', + initialCwd: '/repo/route-aware', status: 'idle', }, })) @@ -2969,10 +3137,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({ @@ -3281,6 +3538,7 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', resumeSessionId: 'thread-refresh', + sessionRef: { provider: 'codex', sessionId: 'thread-refresh' }, }) }) await waitFor(() => { @@ -3350,6 +3608,473 @@ 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('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 + 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('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( diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index bff6097e5..b560960b2 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -894,6 +894,95 @@ 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('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 af4b44101..cbc83540f 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() diff --git a/test/unit/client/store/tabRegistrySync.test.ts b/test/unit/client/store/tabRegistrySync.test.ts index deefeeb43..ceb921c68 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') 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 f5eff62d5..7736a6754 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 @@ -40,7 +54,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 +80,7 @@ function makeAdapter(manager: FakeManager, overrides: Partial undefined, + canonicalizePath: async (value: string) => value, ...overrides, }) } @@ -222,6 +243,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 +251,249 @@ describe('OpenCode serve adapter: create + send', () => { ) }) - it('keeps attached no-cwd sessions sendable without a route argument', async () => { + it('does not validate a placeholder attach before first materialization', async () => { const manager = makeFakeManager() const adapter = makeAdapter(manager) - await adapter.attach?.({ + 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({ + id: 'ses_no_cwd', + time: { updated: 10 }, + }) + manager.listMessages.mockResolvedValueOnce({ messages: [], nextCursor: null }) + const adapter = makeAdapter(manager) + + await expect(adapter.attach?.({ + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'ses_no_cwd', + })).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(/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', - sessionId: 'ses_attached_nocwd', + cwd: '/repo/safe', + })).resolves.toEqual({ + sessionId: 'ses_recovered', + sessionRef: { provider: 'opencode', sessionId: 'ses_recovered' }, }) - await adapter.send?.('ses_attached_nocwd', { text: 'continue' }) + 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 attach 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', + })).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.kill?.('ses_kill_no_cwd')).rejects.toThrow(/cwd/i) + }) + + 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('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) @@ -362,7 +610,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' }) @@ -417,7 +665,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' }) @@ -441,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') @@ -483,6 +752,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 d5d86961b..2122693ce 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) => { diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts index 35606cd01..a1fed14a6 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, @@ -243,6 +253,101 @@ 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('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('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' }), @@ -289,6 +394,311 @@ 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('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('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' }), @@ -321,6 +731,69 @@ 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.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', + 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() diff --git a/test/unit/server/ws-handler-backpressure.test.ts b/test/unit/server/ws-handler-backpressure.test.ts index b44372c74..5ce330fa4 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) 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 1929b4dda..65c74396a 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-ownership.test.ts b/test/unit/server/ws-handler-fresh-agent-ownership.test.ts index fb26dbf74..b4da04710 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,434 @@ describe('WsHandler fresh-agent ownership', () => { await new Promise((resolve) => server.close(() => resolve())) } }) + + 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('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(() => { + 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 = { + 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())) + } + }) + + 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' }), + 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 8a58efdf2..53cc0260c 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,75 @@ 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', + cwd: '/repo/allowed', + 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' } @@ -525,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', @@ -533,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, @@ -547,6 +624,124 @@ 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() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + 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 cwd = '/repo/stale-replay' + + const createMessage = { + type: 'freshAgent.create', + requestId: 'req-stale-replay', + sessionType: 'freshopencode', + provider: 'opencode', + cwd, + } + + 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', + cwd, + 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', + cwd, + })) + + await vi.waitFor(() => { + expect(runtimeManager.kill).toHaveBeenCalledWith({ + sessionId: 'ses_stale_1', + sessionType: 'freshopencode', + provider: 'opencode', + cwd, + }) + }) + + 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()