From 4e88560af7fa9e036d9c6cd5529d5bf4ef8c7eb5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 21:40:11 -0700 Subject: [PATCH] Revert fresh-agent progressive hydration This reverts d9bdc21279900101140a33178d04d1a6c2814e50 while preserving later unrelated work from #467 and #469. --- ...06-21-fresh-agent-progressive-hydration.md | 1397 ----------------- server/agent-api/router.ts | 54 +- server/fresh-agent/adapters/claude/adapter.ts | 2 +- server/fresh-agent/adapters/codex/adapter.ts | 109 +- .../fresh-agent/adapters/opencode/adapter.ts | 61 +- .../adapters/opencode/history-query.ts | 670 ++++++++ .../adapters/opencode/history-runner.ts | 255 +++ .../adapters/opencode/history-worker.ts | 125 ++ .../adapters/opencode/normalize.ts | 9 +- .../history/claude/history-service.ts | 13 +- server/fresh-agent/runtime-adapter.ts | 5 + server/fresh-agent/runtime-manager.ts | 2 +- server/ws-handler.ts | 1 + shared/fresh-agent-turns.ts | 23 - shared/fresh-agent.ts | 31 +- shared/read-models.ts | 10 +- shared/ws-protocol.ts | 5 + .../fresh-agent/FreshAgentTranscript.tsx | 87 +- src/components/fresh-agent/FreshAgentView.tsx | 271 +--- src/lib/api.ts | 2 +- src/lib/fresh-agent-ws.ts | 32 +- src/store/freshAgentSlice.ts | 239 +-- src/store/freshAgentThunks.ts | 68 +- src/store/freshAgentTypes.ts | 7 - src/store/panesSlice.ts | 5 +- src/store/persistMiddleware.ts | 20 +- ...-agent-claude-history-route-parity.test.ts | 12 +- test/server/agent-api-fresh-agent.test.ts | 86 +- .../fresh-agent/FreshAgentTranscript.test.tsx | 46 - .../fresh-agent/FreshAgentView.test.tsx | 186 +-- test/unit/client/lib/api.test.ts | 16 +- .../unit/client/store/freshAgentSlice.test.ts | 134 -- .../client/store/panesPersistence.test.ts | 37 - test/unit/client/store/panesSlice.test.ts | 29 - test/unit/client/store/tabsSlice.test.ts | 6 +- .../layout-store-fresh-agent.test.ts | 46 +- .../claude-history-include-bodies.test.ts | 6 - .../claude-history-service.test.ts | 32 +- .../server/fresh-agent/codex-adapter.test.ts | 242 +-- .../opencode-history-query.test.ts | 675 ++++++++ .../opencode-history-runner.test.ts | 217 +++ .../opencode-history-worker.test.ts | 225 +++ .../opencode-serve-adapter.test.ts | 50 +- test/unit/server/fresh-agent/router.test.ts | 13 +- .../server/ws-handler-fresh-agent.test.ts | 11 +- test/unit/shared/fresh-agent-turns.test.ts | 24 - test/unit/shared/fresh-agent.test.ts | 46 - 47 files changed, 2477 insertions(+), 3165 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-21-fresh-agent-progressive-hydration.md create mode 100644 server/fresh-agent/adapters/opencode/history-query.ts create mode 100644 server/fresh-agent/adapters/opencode/history-runner.ts create mode 100644 server/fresh-agent/adapters/opencode/history-worker.ts create mode 100644 test/unit/server/fresh-agent/opencode-history-query.test.ts create mode 100644 test/unit/server/fresh-agent/opencode-history-runner.test.ts create mode 100644 test/unit/server/fresh-agent/opencode-history-worker.test.ts delete mode 100644 test/unit/shared/fresh-agent.test.ts diff --git a/docs/superpowers/plans/2026-06-21-fresh-agent-progressive-hydration.md b/docs/superpowers/plans/2026-06-21-fresh-agent-progressive-hydration.md deleted file mode 100644 index 0b91e238b..000000000 --- a/docs/superpowers/plans/2026-06-21-fresh-agent-progressive-hydration.md +++ /dev/null @@ -1,1397 +0,0 @@ -# Fresh Agent Progressive Hydration 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:** Restore fresh-agent panes from the newest useful transcript page first, then load older transcript pages through one canonical fresh-agent hydration path with clear restore errors. - -**Architecture:** The server owns provider-specific history reading and always emits the shared fresh-agent snapshot/page/body contracts. The client renders transcript history from paged fresh-agent state, not from terminal replay and not from provider-specific fallback paths. Freshopencode runtime restore accepts canonical OpenCode `ses_*` ids; unrecoverable old placeholder state surfaces a clear error instead of being silently guessed. - -**Tech Stack:** React 18, Redux Toolkit, Express read-model routes, Zod shared contracts, Vitest, Testing Library. - -## Global Constraints - -- Fresh-agent behavior only. Do not modify terminal/CLI restore behavior or terminal replay behavior. -- Do not modify Codex app-server protocol wrappers for this change; freshcodex hydration should use the existing metadata read and turn-list methods. -- Keep one canonical fresh-agent hydration path: provider adapter -> shared snapshot/page/body contract -> Redux history state -> transcript renderer. -- Restore first renders the newest page, normalized by the server into chronological display order, anchored at the latest turn; older pages are prepended above it. -- After the first page renders, start bounded background catch-up immediately. Do not depend on provider cursors staying valid until a later user scroll. -- Use existing fresh-agent snapshot/page/body routes and thunks instead of inventing a new streaming transport. -- Revision-less `/turns` reads are allowed only for the first page. Older-page cursor reads must carry the revision returned by the first page. -- Turn-page `bodies` must be merged into the rendered turn list when `includeBodies=true`; summary-only page entries are not enough for a restored transcript. -- History page, body, and failure actions must carry a hydration request key or equivalent generation guard. Reducers must ignore stale results after the pane target changes, after a newer first-page request starts, or after a clear restore error is set. -- Live/local transcript turns are temporary overlays. Incoming durable page turns must dedupe by contract fields only: non-temporary `turnId`, non-temporary `id`, and `messageId`. Local echo reconciliation that needs pending-send metadata stays in `FreshAgentView` through the existing `localEchoLanded` path; do not add non-contract fields to `FreshAgentTurn`. -- Do not add a hidden legacy runtime fallback. If old/corrupt Freshopencode state cannot be normalized to a canonical `ses_*` id, fail with a visible restore error. -- If old Freshopencode state contains both a temporary `freshopencode-*` id and a canonical `ses_*` id, normalize it to the canonical `ses_*` id before using the main path. -- Provider-specific code stays inside provider adapters; the React transcript path must not branch on Claude/Codex/OpenCode history formats. -- Provider adapters must normalize native provider page ordering before returning `FreshAgentTurnPage`. The client always receives page turns oldest-to-newest within that page, and `nextCursor` always means older history. -- Do not auto-load unbounded transcript history in one blocking request. Load the newest page immediately, then drain older pages in small background batches until complete or until a clear safety cap/error is reached; transcript virtualization is a separate measured follow-up. -- Run npm commands in this worktree with `env -u NODE_ENV -u INIT_CWD ...` so inherited self-hosted app environment does not resolve dependencies from the main checkout. - ---- - -## File Structure - -- Modify `shared/read-models.ts`: make fresh-agent turn-page `revision` optional for first-page reads. -- Modify `server/fresh-agent/runtime-adapter.ts`: allow snapshot/page adapter options without changing the shared output contract. -- Modify `server/fresh-agent/runtime-manager.ts`: pass optional page revisions through and keep contract validation centralized. -- Modify `server/fresh-agent/router.ts`: accept revision-less `/turns` requests and return provider errors clearly. -- Modify `server/fresh-agent/history/claude/history-service.ts`: use the current history revision when a first-page request omits `revision` and normalize returned page order for display. -- Modify `server/fresh-agent/adapters/claude/adapter.ts`: forward optional revisions to the history service. -- Modify `server/fresh-agent/adapters/codex/adapter.ts`: derive first-page revision from `readThread({ includeTurns: false })` metadata plus `thread/turns/list`; do not use full-thread transcript reads as the normal restore or snapshot path. -- Modify `server/fresh-agent/adapters/opencode/adapter.ts`: keep canonical `ses_*` restore, remove runtime placeholder guessing, and make placeholder restore failures clear. -- Modify `server/fresh-agent/adapters/opencode/normalize.ts`: include page bodies when requested by the OpenCode adapter. -- Modify `shared/fresh-agent-turns.ts`: add contract-field turn identity helpers for durable history plus live overlays. -- Modify `shared/fresh-agent.ts`: normalize Freshopencode placeholder persisted identity to canonical `ses_*` when possible or to a visible restore error when not. -- Modify `src/store/freshAgentSlice.ts`: merge first/older pages into durable history, dedupe by contract identity, ignore stale page results, ensure page actions initialize sessions, and keep live turns as an overlay. -- Modify `src/store/freshAgentTypes.ts`: add page loading/backfill state plus hydration request guard fields. -- Modify `src/store/freshAgentThunks.ts`: support first-page loads without a revision and add bounded background catch-up for older pages. -- Modify `src/lib/api.ts`: make `revision` optional for fresh-agent turn-page requests. -- Modify `src/components/fresh-agent/FreshAgentView.tsx`: start first-page hydration immediately, use Redux page history plus live-turn overlay as transcript source, keep snapshot metadata separate, and preflight legacy Freshopencode placeholder state into a visible restore error. -- Modify `src/components/fresh-agent/FreshAgentTranscript.tsx`: show older-history loading/error state and preserve scroll anchoring while older pages are prepended. -- Modify `src/store/persistMiddleware.ts`: never persist temporary Freshopencode ids as durable restore identity. -- Modify `server/agent-api/layout-store.ts`: apply the same Freshopencode identity normalization to server-held layouts. -- Test `test/unit/server/fresh-agent/router.test.ts`: route accepts first-page `/turns` without revision. -- Test `test/unit/server/fresh-agent/claude-history-service.test.ts`: revision-less first-page read returns current revision, returns turns in display order, and keeps stale cursor checks. -- Test `test/unit/server/fresh-agent/codex-adapter.test.ts`: revision-less first-page read uses metadata-only `thread/read` plus `thread/turns/list`, avoids full-thread reads, and rejects later revision drift. -- Test `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: placeholder resume fails clearly and canonical `ses_*` restore still works. -- Test `test/unit/shared/fresh-agent-turns.test.ts`: durable turns dedupe by non-temporary `turnId`/`id` and `messageId` without relying on non-contract aliases. -- Test `test/unit/client/store/freshAgentSlice.test.ts`: first page replaces, older page prepends with dedupe, stale page results are ignored, and live turns reconcile without losing restored pages. -- Test `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx`: top loading control calls `onLoadOlder`, displays errors, and preserves scroll position when older turns are prepended. -- Test `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`: restore renders the first page from `/turns`, loads older pages using `nextCursor`, and surfaces restore errors. -- Test `test/unit/client/store/persistedState.test.ts` and `test/unit/server/agent-api/layout-store-fresh-agent.test.ts`: Freshopencode placeholders normalize at persisted and server-layout boundaries. - ---- - -### Task 1: Revision-Less First Page Reads - -**Files:** -- Modify: `shared/read-models.ts` -- Modify: `server/fresh-agent/runtime-adapter.ts` -- Modify: `server/fresh-agent/runtime-manager.ts` -- Modify: `server/fresh-agent/router.ts` -- Modify: `server/fresh-agent/history/claude/history-service.ts` -- Modify: `server/fresh-agent/adapters/claude/adapter.ts` -- Modify: `server/fresh-agent/adapters/codex/adapter.ts` -- Modify: `server/fresh-agent/adapters/opencode/adapter.ts` -- Modify: `server/fresh-agent/adapters/opencode/normalize.ts` -- Test: `test/unit/server/fresh-agent/router.test.ts` -- Test: `test/unit/server/fresh-agent/claude-history-service.test.ts` -- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts` -- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` -- Test: `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` - -**Interfaces:** -- Consumes: existing `FreshAgentTurnPageSchema`, `FreshAgentThreadTurnsQuerySchema`, provider `getTurnPage`. -- Produces: `/api/fresh-agent/threads/:sessionType/:provider/:threadId/turns` accepts omitted `revision` only when `cursor` is omitted, returns `revision` in the response, and returns page turns oldest-to-newest within each page. - -- [ ] **Step 1: Write failing route test** - -Add to `test/unit/server/fresh-agent/router.test.ts`: - -```ts -it('loads the first fresh-agent turn page without requiring a client revision', async () => { - const runtimeManager = { - getTurnPage: vi.fn(async () => ({ - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'ses_first_page', - revision: 123, - nextCursor: 'older-cursor', - turns: [], - bodies: {}, - })), - } - const app = createFreshAgentRouterHarness({ runtimeManager }) - - const response = await request(app) - .get('/api/fresh-agent/threads/freshopencode/opencode/ses_first_page/turns?limit=40&includeBodies=true') - .expect(200) - - expect(response.body.revision).toBe(123) - expect(response.body.nextCursor).toBe('older-cursor') - expect(runtimeManager.getTurnPage).toHaveBeenCalledWith(expect.objectContaining({ - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'ses_first_page', - revision: undefined, - limit: 40, - includeBodies: true, - })) -}) -``` - -If `router.test.ts` does not already have `createFreshAgentRouterHarness`, add a small local helper or adapt the existing `FreshAgentRuntimeManager` test setup; do not leave the snippet as a reference to a non-existent helper. - -- [ ] **Step 2: Run route test to verify it fails** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts --run` - -Expected: FAIL because `FreshAgentThreadTurnsQuerySchema` currently requires `revision`. - -- [ ] **Step 3: Make first-page route query revision optional** - -In `shared/read-models.ts`, change the query schema and add a refinement that keeps cursor reads versioned: - -```ts -export const FreshAgentThreadTurnsQuerySchema = z.object({ - cursor: z.string().min(1).optional(), - priority: ReadModelPrioritySchema.optional(), - revision: z.coerce.number().int().nonnegative().optional(), - cwd: z.string().trim().min(1).optional(), - limit: z.number().int().positive().max(MAX_FRESH_AGENT_THREAD_TURNS).optional(), - includeBodies: z.union([ - z.boolean(), - z.enum(['true', 'false']).transform((v) => v === 'true'), - ]).optional(), -}).superRefine((value, ctx) => { - if (value.cursor && value.revision == null) { - ctx.addIssue({ - code: 'custom', - path: ['revision'], - message: 'revision is required when cursor is provided', - }) - } -}) -``` - -In `server/fresh-agent/runtime-manager.ts`, change the `getTurnPage` input type: - -```ts -revision?: number -``` - -Do not change `FreshAgentTurnPageSchema`; every provider must still return a concrete `revision`. - -Add a route assertion that cursor requests without a revision are rejected: - -```ts -await request(app) - .get('/api/fresh-agent/threads/freshopencode/opencode/ses_first_page/turns?cursor=older') - .expect(400) -``` - -- [ ] **Step 4: Write failing Claude history and adapter tests** - -Update existing `test/unit/server/fresh-agent/claude-history-service.test.ts` expectations that intentionally change under the new contract: - -- Rename `returns recent-first timeline pages with a cursor` to `returns display-ordered newest timeline pages with a cursor`. -- Change its first-page expectation from `['latest user turn', 'middle assistant turn']` to `['middle assistant turn', 'latest user turn']`. -- Replace `rejects timeline-page reads that omit the accepted restore revision` with a test that omits `revision` on a cursorless first-page request and expects the current history revision. - -Add or update a service test: - -```ts -it('loads the newest page with the current revision when no revision is supplied', async () => { - const service = createClaudeHistoryServiceHarness() - await service.writeTranscript('session-a', [ - userRecord('turn-1', 'first'), - assistantRecord('turn-2', 'second'), - ]) - - const page = await service.getThreadTurnPage({ - sessionId: 'session-a', - limit: 1, - includeBodies: true, - }) - - expect(page.revision).toBeGreaterThan(0) - expect(page.items).toHaveLength(1) - expect(page.nextCursor).toEqual(expect.any(String)) - expect(page.bodies?.[page.items[0].turnId]).toBeDefined() -}) -``` - -Adapt this snippet to the existing `claude-history-service.test.ts` harness: construct the service with `createClaudeFreshAgentHistoryService({ agentHistorySource: { resolve } })` and use the file's existing `toResolvedHistory`/message fixtures instead of inventing new permanent helpers. - -Add a second assertion with `limit: 2` that the newest page is display ordered, using turn IDs that the fixture actually creates: - -```ts -expect(page.items.map((item) => item.summary)).toEqual(['middle assistant turn', 'latest user turn']) -``` - -Add to `test/unit/server/fresh-agent/claude-adapter.test.ts`: - -```ts -it('forwards omitted first-page revisions to the history service as undefined', async () => { - const historyService = { - getSnapshot: vi.fn(), - getThreadTurnPage: vi.fn().mockResolvedValue({ - sessionType: 'freshclaude', - sessionId: 'claude-session-1', - revision: 13, - items: [], - nextCursor: null, - bodies: {}, - }), - getTurnBody: vi.fn(), - } - const adapter = createClaudeFreshAgentAdapter({ - sdkBridge: { createSession: vi.fn() } as any, - historyService: historyService as any, - }) - - await adapter.getTurnPage?.( - { sessionType: 'freshclaude', provider: 'claude', threadId: 'claude-session-1' }, - { limit: 40, includeBodies: true }, - ) - - expect(historyService.getThreadTurnPage).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: 'claude-session-1', - revision: undefined, - })) -}) -``` - -- [ ] **Step 5: Implement Claude current-revision fallback** - -In `server/fresh-agent/history/claude/history-service.ts`, replace the hard revision requirement with history-derived revision: - -```ts -const history = await loadHistoryRecords(query.sessionId) -throwIfAborted(query.signal) -const requestedRevision = query.revision ?? history.revision -if (requestedRevision !== history.revision) { - throw new ClaudeFreshAgentStaleHistoryRevisionError(requestedRevision, history.revision) -} -if (cursor && cursor.revision !== history.revision) { - throw new ClaudeFreshAgentStaleHistoryRevisionError(cursor.revision, history.revision) -} -``` - -Keep the existing cursor revision check unchanged. - -Do not change Claude cursor behavior: cursor reads must still be checked against the requested revision and the cursor's embedded revision. - -In `server/fresh-agent/adapters/claude/adapter.ts`, stop coercing missing revision to `NaN`: - -```ts -revision: typeof query.revision === 'number' ? query.revision : undefined, -``` - -Normalize the returned page items to display order after selecting the newest page window: - -```ts -// server/fresh-agent/history/claude/history-service.ts -const pageItems = history.records.slice(offset, offset + limit).reverse() -``` - -- [ ] **Step 6: Write failing Codex adapter tests** - -Add to `test/unit/server/fresh-agent/codex-adapter.test.ts`: - -```ts -it('uses metadata-only read plus turn list for revision-less first-page reads', async () => { - const runtime = { - startThread: vi.fn(), - resumeThread: vi.fn(), - readThread: vi.fn(async () => ({ - thread: { id: 'codex-thread-1', updatedAt: 456, status: 'idle', turns: [] }, - })), - listThreadTurns: vi.fn(async () => ({ - nextCursor: null, - turns: [makeCodexTurn('turn-a')], - })), - readThreadTurn: vi.fn(), - } - const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - - const page = await adapter.getTurnPage?.( - { sessionType: 'freshcodex', provider: 'codex', threadId: 'codex-thread-1' }, - { limit: 10 }, - ) - - expect(page).toMatchObject({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-thread-1', - revision: 456, - }) - expect(runtime.readThread).toHaveBeenCalledWith({ - threadId: 'codex-thread-1', - includeTurns: false, - }) - expect(runtime.listThreadTurns).toHaveBeenCalledWith(expect.objectContaining({ - threadId: 'codex-thread-1', - limit: 1, - itemsView: 'full', - })) - expect(runtime.readThread).not.toHaveBeenCalledWith(expect.objectContaining({ includeTurns: true })) -}) - -it('uses metadata-only snapshots so restored Codex panes do not full-read transcripts', async () => { - const runtime = { - startThread: vi.fn(), - resumeThread: vi.fn(), - readThread: vi.fn(async () => ({ - thread: { id: 'codex-thread-1', updatedAt: 456, status: 'idle', turns: [] }, - })), - listThreadTurns: vi.fn(), - readThreadTurn: vi.fn(), - } - const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - - const snapshot = await adapter.getSnapshot?.({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-thread-1', - }) - - expect(snapshot).toMatchObject({ sessionId: 'codex-thread-1', revision: 456, turns: [] }) - expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'codex-thread-1', includeTurns: false }) - expect(runtime.readThread).not.toHaveBeenCalledWith(expect.objectContaining({ includeTurns: true })) -}) -``` - -- [ ] **Step 7: Implement Codex metadata-only revision discovery** - -In `server/fresh-agent/adapters/codex/adapter.ts`, keep `normalizeDisplayTurnPage`'s `revision` input concrete. Instead, make `getTurnPage` derive a concrete revision before calling it: - -1. For cursor reads, require and pass the caller revision exactly as today. -2. For cursorless first-page reads with no caller revision, call `runtime.readThread({ threadId, includeTurns: false })` and use `thread.updatedAt` as the revision. -3. Then call the existing `thread/turns/list` path through `normalizeDisplayTurnPage` with that revision. If the page includes its own finite revision and it differs from metadata, throw `FreshAgentStaleThreadRevisionError`. -4. If metadata has no finite `updatedAt`, throw `FreshAgentUnprovableThreadRevisionError` with a clear message instead of falling back to a full transcript read. - -Change `getSnapshot` to use `runtime.readThread({ threadId, includeTurns: false })` in the normal path and return a metadata snapshot with `turns: []`. Snapshot is no longer the transcript source; Task 3 uses paged history for transcript display. Do not call `readThread({ includeTurns: true })` in the normal restore path. - -Keep the existing `normalizeDisplayTurnPage` page-revision check, but make it tolerant of providers that omit `rawPage.revision` by treating the concrete metadata revision as the expected page revision. If `rawPage.revision` is present and finite, it must equal the metadata/caller revision. - -- [ ] **Step 8: Add OpenCode revision and body tests** - -Add to `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: - -```ts -it('rejects stale OpenCode older-page revisions instead of silently returning a mismatched page', async () => { - const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', title: 'Kimi chat', time: { updated: 20 } })) - 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' }) - - await expect(adapter.getTurnPage?.( - { sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, - { revision: 19, cursor: 'older-cursor', limit: 40, includeBodies: true }, - )).rejects.toMatchObject({ code: 'STALE_THREAD_REVISION' }) -}) - -it('returns OpenCode page bodies keyed by turn id when includeBodies is true', async () => { - const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', title: 'Kimi chat', time: { updated: 20 } })) - 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' }) - - const page = await adapter.getTurnPage?.( - { sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, - { limit: 40, includeBodies: true }, - ) - - expect(page?.turns.map((turn) => turn.turnId)).toEqual(['msg_user_1', 'msg_assistant_1']) - expect(page?.bodies?.['msg_user_1']?.items[0]).toMatchObject({ kind: 'text', text: 'reply ok' }) -}) -``` - -- [ ] **Step 9: Implement OpenCode revision checks and body map** - -In `server/fresh-agent/adapters/opencode/adapter.ts`, import `FreshAgentStaleThreadRevisionError` from the runtime manager. After `assembleExport` returns a page revision: - -```ts -if (typeof query.revision === 'number' && query.revision !== revision) { - throw new FreshAgentStaleThreadRevisionError(revision) -} -``` - -Pass full page bodies when `query.includeBodies === true`: - -```ts -return normalizeOpencodeTurnPage({ - threadId: thread.threadId, - exported, - revision, - nextCursor, - includeBodies: query.includeBodies === true, -}) -``` - -Update `normalizeOpencodeTurnPage` to include: - -```ts -bodies: includeBodies ? Object.fromEntries(turns.map((turn) => [turn.turnId, turn])) : undefined -``` - -- [ ] **Step 10: Run focused server tests** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- --config vitest.server.config.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/fresh-agent/claude-history-service.test.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/opencode-serve-adapter.test.ts --run` - -Expected: PASS. - -- [ ] **Step 11: Commit** - -```bash -git add shared/read-models.ts server/fresh-agent/runtime-adapter.ts server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts server/fresh-agent/history/claude/history-service.ts server/fresh-agent/adapters/claude/adapter.ts server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/opencode/adapter.ts server/fresh-agent/adapters/opencode/normalize.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/fresh-agent/claude-history-service.test.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/opencode-serve-adapter.test.ts -git commit -m "feat: allow fresh-agent first-page hydration without client revision" -``` - ---- - -### Task 2: Canonical Paged History State - -**Files:** -- Modify: `shared/fresh-agent-turns.ts` -- Modify: `src/store/freshAgentSlice.ts` -- Modify: `src/store/freshAgentTypes.ts` -- Modify: `src/store/freshAgentThunks.ts` -- Modify: `src/lib/api.ts` -- Test: `test/unit/shared/fresh-agent-turns.test.ts` -- Test: `test/unit/client/store/freshAgentSlice.test.ts` -- Test: `test/unit/client/lib/api.test.ts` - -**Interfaces:** -- Consumes: Task 1 revision-less page API. -- Produces: first page replaces the durable hydrated page, older pages prepend by cursor, stale async page results are ignored, live turns are kept as an overlay, and duplicate durable/live turns dedupe only by shared contract fields (`turnId`, `id`, `messageId`). - -- [ ] **Step 1: Write failing identity and slice tests** - -Add to `test/unit/shared/fresh-agent-turns.test.ts`: - -```ts -it('matches durable and live turns by contract messageId', () => { - expect(freshAgentTurnsReferToSameDisplayTurn( - turn('live-assistant-1', { messageId: 'message-a' }), - turn('durable-assistant-1', { messageId: 'message-a' }), - )).toBe(true) -}) - -it('does not treat generated live ids as durable identity by themselves', () => { - expect(freshAgentTurnsReferToSameDisplayTurn( - turn('live-assistant-1'), - turn('live-assistant-1'), - )).toBe(false) -}) -``` - -If `fresh-agent-turns.test.ts` does not already have a `turn()` helper, add a local helper that returns a strict `FreshAgentTurn` with `id`, `turnId`, `summary`, and `items`. - -Add to `test/unit/client/store/freshAgentSlice.test.ts`: - -```ts -it('replaces history with the newest page and stores its older cursor', () => { - const state = reducer(undefined, historyPageReceived({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - turns: [turn('newer-user'), turn('newer-agent')], - nextCursor: 'older-1', - revision: 10, - })) - - const session = state.sessions['freshopencode:opencode:ses_a'] - expect(session.historyItems.map((item) => item.turnId)).toEqual(['newer-user', 'newer-agent']) - expect(session.nextHistoryCursor).toBe('older-1') - expect(session.historyRevision).toBe(10) -}) - -it('prepends older pages and dedupes repeated boundary turns', () => { - let state = reducer(undefined, historyPageReceived({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - turns: [turn('turn-2'), turn('turn-3')], - nextCursor: 'older-1', - revision: 10, - })) - - state = reducer(state, historyPageReceived({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - cursor: 'older-1', - turns: [turn('turn-1'), turn('turn-2')], - nextCursor: null, - revision: 10, - })) - - expect(state.sessions['freshopencode:opencode:ses_a'].historyItems.map((item) => item.turnId)) - .toEqual(['turn-1', 'turn-2', 'turn-3']) - expect(state.sessions['freshopencode:opencode:ses_a'].nextHistoryCursor).toBeNull() -}) - -it('keeps live assistant turns as a display overlay without dropping restored history', () => { - let state = reducer(undefined, historyPageReceived({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - turns: [turn('turn-1')], - nextCursor: null, - revision: 10, - })) - - state = reducer(state, addAssistantMessage({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - content: [{ type: 'text', text: 'live answer' }], - })) - - const session = state.sessions['freshopencode:opencode:ses_a'] - expect(session.historyItems.map((item) => item.summary)).toEqual(['turn-1']) - expect(selectFreshAgentTranscriptTurns(session).map((item) => item.summary)) - .toEqual(['turn-1', 'live answer']) -}) - -it('dedupes a live overlay when durable history shares the same contract messageId', () => { - const session = freshAgentSessionState({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - historyItems: [turn('durable-assistant-1', { messageId: 'message-a' })], - turns: [turn('live-assistant-1', { messageId: 'message-a', source: 'live' })], - }) - - expect(selectFreshAgentTranscriptTurns(session).map((item) => item.turnId)) - .toEqual(['durable-assistant-1']) -}) - -it('ignores stale page results after a newer first-page request starts', () => { - let state = reducer(undefined, historyLoadStarted({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - requestKey: 'hydrate:old', - })) - state = reducer(state, historyLoadStarted({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - requestKey: 'hydrate:new', - })) - - state = reducer(state, historyPageReceived({ - sessionId: 'ses_a', - sessionType: 'freshopencode', - provider: 'opencode', - requestKey: 'hydrate:old', - turns: [turn('stale-turn')], - bodies: {}, - nextCursor: null, - revision: 9, - })) - - expect(state.sessions['freshopencode:opencode:ses_a'].historyItems).toEqual([]) -}) -``` - -Use the existing slice test session factory if one exists; otherwise add a small local `freshAgentSessionState` helper that returns a valid `FreshAgentSessionState` with sensible defaults. - -- [ ] **Step 2: Run slice test to verify it fails** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- test/unit/shared/fresh-agent-turns.test.ts test/unit/client/store/freshAgentSlice.test.ts --run` - -Expected: FAIL because older pages replace rather than prepend and live turns reset history. - -- [ ] **Step 3: Implement contract-field history merge helpers** - -In `shared/fresh-agent-turns.ts`, keep `getFreshAgentDisplayTurnKey` for stable durable IDs and add contract-field identity helpers. Do not add `requestId`, `submittedTurnId`, or any other non-contract field to `FreshAgentTurnSchema`. - -```ts -export function isTemporaryFreshAgentTurnId(value: string | undefined): boolean { - return typeof value === 'string' && ( - value.startsWith('live-') - || value.startsWith('__local-echo:') - ) -} - -export function getFreshAgentTurnIdentityKeys(turn: Pick): string[] { - const keys = new Set() - for (const candidate of [turn.turnId, turn.id]) { - if (candidate && !isTemporaryFreshAgentTurnId(candidate)) keys.add(`turn:${candidate}`) - } - if (turn.messageId) keys.add(`message:${turn.messageId}`) - return [...keys] -} - -export function freshAgentTurnsReferToSameDisplayTurn(a: FreshAgentTurn, b: FreshAgentTurn): boolean { - const aKeys = new Set(getFreshAgentTurnIdentityKeys(a)) - return getFreshAgentTurnIdentityKeys(b).some((key) => aKeys.has(key)) -} -``` - -Import the shared helpers and add merge helpers near `resetHydratedHistoryState` in `src/store/freshAgentSlice.ts`: - -```ts -import { - getFreshAgentTurnIdentityKeys, -} from '@shared/fresh-agent-turns' - -function mergeUniqueTurnsByIdentity( - first: FreshAgentSessionState['historyItems'], - second: FreshAgentSessionState['historyItems'], -): FreshAgentSessionState['historyItems'] { - const seen = new Set() - const merged: FreshAgentSessionState['historyItems'] = [] - for (const turn of [...first, ...second]) { - const keys = getFreshAgentTurnIdentityKeys(turn) - if (keys.some((key) => seen.has(key))) continue - for (const key of keys) seen.add(key) - merged.push(turn) - } - return merged -} - -function appendLiveTurn(session: FreshAgentSessionState, turn: FreshAgentSessionState['historyItems'][number]): void { - session.turns = mergeUniqueTurnsByIdentity(session.turns, [turn]) - session.historyBodies[turn.turnId] = turn -} -``` - -Add these fields to `FreshAgentSessionState` in `src/store/freshAgentTypes.ts`: - -```ts -historyInitialLoading?: boolean -historyOlderLoading?: boolean -historyOlderError?: string -historyBackfillComplete?: boolean -historyBackfillPaused?: boolean -historyInitialRequestKey?: string -historyOlderRequestKey?: string -``` - -Update `historyLoadStarted` and `historyPageReceived` so page actions create the session entry before the snapshot arrives: - -```ts -const session = resolveOrEnsureSession(state, action.payload) -if (!session) return -``` - -Use `action.payload.cursor` to set loading state: - -```ts -if (action.payload.cursor) { - session.historyOlderLoading = true - session.historyOlderError = undefined - session.historyOlderRequestKey = action.payload.requestKey -} else { - session.historyInitialLoading = true - session.historyError = undefined - session.historyInitialRequestKey = action.payload.requestKey -} -``` - -Before applying `historyPageReceived` or `historyLoadFailed`, ignore stale results: - -```ts -const expectedRequestKey = action.payload.cursor - ? session.historyOlderRequestKey - : session.historyInitialRequestKey -if (action.payload.requestKey && expectedRequestKey && action.payload.requestKey !== expectedRequestKey) { - return -} -if (session.restoreFailureMessage) return -``` - -Update `historyPageReceived`: - -```ts -const incoming = action.payload.turns -session.historyInitialLoading = false -session.historyOlderLoading = false -session.historyLoading = false -session.historyLoaded = true -session.historyItems = action.payload.cursor - ? mergeUniqueTurnsByIdentity(incoming, session.historyItems) - : incoming -for (const turn of incoming) { - session.historyBodies[turn.turnId] = turn -} -for (const [turnId, body] of Object.entries(action.payload.bodies ?? {})) { - session.historyBodies[turnId] = body -} -session.nextHistoryCursor = action.payload.nextCursor -session.historyRevision = action.payload.revision ?? session.historyRevision -session.historyBackfillComplete = action.payload.nextCursor == null -session.historyBackfillPaused = false -``` - -Add and export a selector-style helper from `src/store/freshAgentSlice.ts`: - -```ts -export function selectFreshAgentTranscriptTurns(session: FreshAgentSessionState): FreshAgentTurn[] { - return mergeUniqueTurnsByIdentity(session.historyItems, session.turns) -} -``` - -Update `addUserMessage` and `addAssistantMessage` to build contract-valid `FreshAgentTurn` objects and call `appendLiveTurn(session, turn)` instead of assigning `session.historyItems = session.turns`. - -Update `turnBodyReceived` to ignore stale bodies if `action.payload.revision` does not equal the current `session.historyRevision`. - -- [ ] **Step 4: Make API and thunk revisions optional** - -In `src/lib/api.ts`, change the `getFreshAgentTurnPage` query type: - -```ts -revision?: number -``` - -In `src/store/freshAgentThunks.ts`, change input: - -```ts -revision?: number -requestKey?: string -priority?: 'visible' | 'background' -``` - -Keep `revision` in the query string only when defined. - -Ensure caller code supplies `revision` whenever it supplies `cursor`; the route rejects cursor-without-revision. - -Forward `priority` to `getFreshAgentTurnPage`: - -```ts -const page = await getFreshAgentTurnPage( - input.sessionType, - input.provider, - input.sessionId, - { - revision: input.revision, - cursor: input.cursor, - priority: input.priority, - limit: input.limit, - includeBodies: input.includeBodies, - cwd: input.cwd, - signal: controller.signal, - }, -) -``` - -When dispatching page actions, carry the request key and page bodies: - -```ts -dispatch(historyLoadStarted(input)) -// ... -dispatch(historyPageReceived({ - ...input, - turns: page.turns, - bodies: page.bodies ?? {}, - nextCursor: page.nextCursor, - revision: page.revision, -})) -``` - -Add a new thunk in `src/store/freshAgentThunks.ts`: - -```ts -const BACKGROUND_HISTORY_MAX_PAGES_PER_BATCH = 8 - -export const backfillFreshAgentOlderHistory = createAsyncThunk( - 'freshAgent/backfillOlderHistory', - async ( - input: FreshAgentThreadThunkLocator & { - revision: number - cursor: string - requestKey: string - limit?: number - }, - { dispatch }, - ) => { - let cursor: string | null | undefined = input.cursor - let revision = input.revision - for (let page = 0; cursor && page < BACKGROUND_HISTORY_MAX_PAGES_PER_BATCH; page += 1) { - const result = await dispatch(loadFreshAgentThreadTurns({ - ...input, - revision, - cursor, - priority: 'background', - limit: input.limit ?? 40, - includeBodies: true, - })).unwrap() - cursor = result.nextCursor - revision = result.revision - } - return { nextCursor: cursor ?? null, revision } - }, -) -``` - -If the thunk sees an invalid/expired cursor error, dispatch a clear `historyLoadFailed` message: `Older history cursor expired; refresh history to continue.` Do not silently fall back to a different path. - -- [ ] **Step 5: Add API test for revision-less page request** - -Add to `test/unit/client/lib/api.test.ts`: - -```ts -it('omits revision when loading the first fresh-agent turn page', async () => { - mockApiGet.mockResolvedValue({ - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'ses_a', - revision: 10, - nextCursor: null, - turns: [], - }) - - await getFreshAgentTurnPage('freshopencode', 'opencode', 'ses_a', { limit: 40, includeBodies: true }) - - expect(mockApiGet).toHaveBeenCalledWith( - '/api/fresh-agent/threads/freshopencode/opencode/ses_a/turns?limit=40&includeBodies=true', - expect.any(Object), - ) -}) -``` - -- [ ] **Step 6: Run focused client state/API tests** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- test/unit/shared/fresh-agent-turns.test.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/lib/api.test.ts --run` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add shared/fresh-agent-turns.ts src/store/freshAgentSlice.ts src/store/freshAgentTypes.ts src/store/freshAgentThunks.ts src/lib/api.ts test/unit/shared/fresh-agent-turns.test.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/lib/api.test.ts -git commit -m "feat: merge fresh-agent history pages in canonical state" -``` - ---- - -### Task 3: Progressive Restore UI - -**Files:** -- Modify: `src/components/fresh-agent/FreshAgentView.tsx` -- Modify: `src/components/fresh-agent/FreshAgentTranscript.tsx` -- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` -- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` - -**Interfaces:** -- Consumes: Task 2 `historyItems`, `turns`, `nextHistoryCursor`, `historyLoading`, `historyError`, `selectFreshAgentTranscriptTurns`, request-key guarded thunks, and revision-less first-page thunk. -- Produces: first page renders from `/turns`; bounded background catch-up starts after the first page; top-of-scroll can request more if catch-up pauses; snapshot metadata still drives controls, approvals, questions, and status. - -- [ ] **Step 1: Write failing FreshAgentView restore test** - -Add to `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`: - -```ts -it('renders the newest turn page before relying on snapshot turns', async () => { - deferFreshAgentSnapshot() - mockGetFreshAgentTurnPage.mockResolvedValue({ - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'ses_restore', - revision: 22, - nextCursor: 'older-page', - turns: [freshAgentTurn('turn-newest', 'assistant', 'Newest restored answer')], - bodies: {}, - }) - - renderFreshAgentView({ - sessionType: 'freshopencode', - provider: 'opencode', - sessionId: 'ses_restore', - sessionRef: { provider: 'opencode', sessionId: 'ses_restore' }, - status: 'connected', - }) - - expect(await screen.findByText('Newest restored answer')).toBeInTheDocument() - expect(mockGetFreshAgentTurnPage).toHaveBeenCalledWith( - 'freshopencode', - 'opencode', - 'ses_restore', - expect.objectContaining({ limit: 40, includeBodies: true, revision: undefined }), - ) -}) -``` - -Before adding the tests, update the file's `@/lib/api` mock to include `getFreshAgentTurnPage`, and add or reuse local helpers for deferred snapshots, rendering, and strict `FreshAgentTurn` fixtures. Do not let these tests hit the real `api.get` path in jsdom. - -Add a second test that resolves the first page with `nextCursor` and verifies bounded background catch-up starts with the first page revision and cursor: - -```ts -expect(mockGetFreshAgentTurnPage).toHaveBeenNthCalledWith( - 2, - 'freshopencode', - 'opencode', - 'ses_restore', - expect.objectContaining({ - cursor: 'older-page', - revision: 22, - priority: 'background', - includeBodies: true, - }), -) -``` - -- [ ] **Step 2: Write failing Transcript older-page test** - -Add to `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx`: - -```ts -it('loads older history from the top control', async () => { - const onLoadOlder = vi.fn().mockResolvedValue(undefined) - render( - , - ) - - await userEvent.click(screen.getByRole('button', { name: /load older/i })) - - expect(onLoadOlder).toHaveBeenCalledTimes(1) -}) - -it('shows history load errors without hiding restored turns', () => { - render( - , - ) - - expect(screen.getByText('Second')).toBeInTheDocument() - expect(screen.getByText('Could not load older history')).toBeInTheDocument() -}) -``` - -If `FreshAgentTranscript.test.tsx` does not already have `freshAgentTurn(...)`, add a local helper that returns a strict `FreshAgentTurn`. - -- [ ] **Step 3: Wire FreshAgentView to canonical history state** - -In `src/components/fresh-agent/FreshAgentView.tsx`, import the thunks and selector helper: - -```ts -import { backfillFreshAgentOlderHistory, loadFreshAgentThreadTurns } from '@/store/freshAgentThunks' -import { selectFreshAgentTranscriptTurns } from '@/store/freshAgentSlice' -``` - -Select the current fresh-agent session by the same id used for page loading: - -```ts -const transcriptSessionId = snapshotThreadId ?? paneContent.sessionId -const freshAgentSessionKey = transcriptSessionId - ? makeFreshAgentSessionKey({ - sessionId: transcriptSessionId, - sessionType: paneContent.sessionType, - provider: paneContent.provider, - }) - : null -const freshAgentSession = useAppSelector((state) => ( - freshAgentSessionKey ? state.freshAgent.sessions[freshAgentSessionKey] : undefined -)) -``` - -Start the first page as soon as `snapshotThreadId` exists: - -```ts -useEffect(() => { - if (!snapshotThreadId) return - const current = paneContentRef.current - const requestKey = [ - current.createRequestId, - current.sessionType, - current.provider, - snapshotThreadId, - ].join(':') - void dispatch(loadFreshAgentThreadTurns({ - sessionId: snapshotThreadId, - sessionType: current.sessionType, - provider: current.provider, - cwd: current.initialCwd, - limit: 40, - includeBodies: true, - requestKey, - })).unwrap().then((page) => { - if (!page.nextCursor) return - void dispatch(backfillFreshAgentOlderHistory({ - sessionId: snapshotThreadId, - sessionType: current.sessionType, - provider: current.provider, - cwd: current.initialCwd, - revision: page.revision, - cursor: page.nextCursor, - requestKey, - limit: 40, - })) - }).catch(() => {}) -}, [dispatch, snapshotThreadId, paneContent.provider, paneContent.sessionType]) -``` - -When snapshot resolves, keep metadata but do not replace page history: - -```ts -dispatch(freshAgentSnapshotReceived({ snapshot: displaySnapshot, hydrateHistory: false })) -``` - -Add `hydrateHistory?: boolean` to the `freshAgentSnapshotReceived` payload. When false, update `snapshot`, status, metadata, approvals, questions, token usage, and `historyRevision`, but do not overwrite `historyItems`, `historyBodies`, or live `turns`. - -Use canonical page history plus live overlay as the transcript source: - -```ts -const turns = freshAgentSession ? selectFreshAgentTranscriptTurns(freshAgentSession) : [] -``` - -Replace snapshot-only transcript decisions with the same `turns` source: - -- Local echo landing should call `localEchoLanded(turns, echo, pendingSendMetadataRef.current.get(echo.requestId))`. -- Auto-title "has user turn" checks should use a new helper such as `freshAgentTurnsHaveUserTurn(turns)` or `freshAgentSnapshotHasUserTurn({ turns })`. -- `rewindToTurn`, fork target lookup, and checkpoint picking should use `turns` rather than `snapshot?.turns`. - -Keep the local echo visual append, but rely on Task 2 reconciliation to remove it when the durable page turn lands. - -- [ ] **Step 4: Add older-page callback** - -In `FreshAgentView.tsx`, add: - -```ts -const olderHistoryCursorInFlightRef = useRef(null) - -const loadOlderHistory = useCallback(() => { - const session = freshAgentSession - if (!session?.nextHistoryCursor || session.historyLoading) return - if (olderHistoryCursorInFlightRef.current === session.nextHistoryCursor) return - olderHistoryCursorInFlightRef.current = session.nextHistoryCursor - void dispatch(loadFreshAgentThreadTurns({ - sessionId: session.sessionId, - sessionType: session.sessionType, - provider: session.provider, - cwd: paneContentRef.current.initialCwd, - revision: session.historyRevision, - cursor: session.nextHistoryCursor, - requestKey: session.historyInitialRequestKey ?? `${session.sessionType}:${session.provider}:${session.sessionId}`, - limit: 40, - includeBodies: true, - })).finally(() => { - if (olderHistoryCursorInFlightRef.current === session.nextHistoryCursor) { - olderHistoryCursorInFlightRef.current = null - } - }) -}, [dispatch, freshAgentSession]) -``` - -Pass to transcript: - -```tsx - -``` - -- [ ] **Step 5: Implement Transcript older-history UI and scroll anchoring** - -Extend props in `FreshAgentTranscript.tsx`: - -```ts -hasOlderHistory?: boolean -isLoadingOlder?: boolean -historyError?: string -onLoadOlder?: () => void | Promise -``` - -Add a top control before `displayTurns.map`: - -```tsx -{hasOlderHistory || historyError ? ( -
- {historyError ? ( - {historyError} - ) : ( - - )} -
-) : null} -``` - -In `onScroll`, trigger near the top: - -```ts -if (node.scrollTop < 48 && hasOlderHistory && !isLoadingOlder) { - void onLoadOlder?.() -} -``` - -Preserve scroll position when prepending older turns by recording `scrollHeight` before `onLoadOlder` and adjusting after `transcriptSignature` changes. - -- [ ] **Step 6: Run focused UI tests** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx --run` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/components/fresh-agent/FreshAgentView.tsx src/components/fresh-agent/FreshAgentTranscript.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx -git commit -m "feat: progressively hydrate fresh-agent transcripts" -``` - ---- - -### Task 4: Clear Freshopencode Restore Errors - -**Files:** -- Modify: `server/fresh-agent/adapters/opencode/adapter.ts` -- Modify: `server/fresh-agent/runtime-adapter.ts` -- Modify: `server/ws-handler.ts` -- Modify: `shared/ws-protocol.ts` -- Modify: `shared/fresh-agent.ts` -- Modify: `src/components/fresh-agent/FreshAgentView.tsx` -- Modify: `src/store/persistedState.ts` -- Modify: `src/store/storage-migration.ts` -- Modify: `src/store/persistMiddleware.ts` -- Modify: `server/agent-api/layout-store.ts` -- Test: `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts` -- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` -- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` -- Test: `test/unit/client/lib/fresh-agent-ws.test.ts` -- Test: `test/unit/client/store/persistedState.test.ts` -- Test: `test/unit/server/agent-api/layout-store-fresh-agent.test.ts` - -**Interfaces:** -- Consumes: existing `FreshAgentLostSessionError`, existing visible restore/load error surfaces. -- Produces: runtime restore of `freshopencode-*` placeholder ids fails clearly; canonical `ses_*` restore remains supported; persisted placeholder-plus-canonical state normalizes to `ses_*`; unrecoverable placeholder-only state becomes a visible restore error. - -- [ ] **Step 1: Write failing OpenCode adapter test** - -Add to `test/unit/server/fresh-agent/opencode-serve-adapter.test.ts`: - -```ts -it('fails legacy freshopencode placeholder resume instead of guessing a durable session', async () => { - const manager = makeFakeManager() - const adapter = makeAdapter(manager) - - await expect(adapter.resume?.({ - requestId: 'restore-1', - sessionType: 'freshopencode', - provider: 'opencode', - resumeSessionId: 'freshopencode-old-placeholder', - cwd: '/repo', - })).rejects.toMatchObject({ - name: 'FreshAgentLostSessionError', - code: 'FRESH_AGENT_LOST_SESSION', - message: expect.stringContaining('cannot be restored because it is not a canonical OpenCode session id'), - }) -}) -``` - -Add persisted-state and layout-store tests: - -```ts -it('normalizes Freshopencode placeholder state to a canonical ses id when one is available', () => { - const content = migrateLegacyFreshAgentContent({ - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-old-placeholder' }, - resumeSessionId: 'ses_real', - }) - - expect(content.sessionRef).toEqual({ provider: 'opencode', sessionId: 'ses_real' }) - expect(content.restoreError).toBeUndefined() -}) - -it('normalizes Freshopencode placeholder-only state to a restore error', () => { - const content = migrateLegacyFreshAgentContent({ - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-old-placeholder' }, - }) - - expect(content.sessionRef).toBeUndefined() - expect(content.resumeSessionId).toBeUndefined() - expect(content.restoreError).toEqual({ - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', - }) -}) -``` - -- [ ] **Step 2: Remove runtime placeholder resolver and stop sending legacy restore context** - -In `server/fresh-agent/adapters/opencode/adapter.ts`, delete: - -```ts -const legacyReader = (): OpencodeHistoryReader => { ... } -async function resolveLegacyPlaceholder(...) { ... } -``` - -Remove any imports that become unused after deleting the runtime placeholder resolver, including legacy history reader/runner types that are no longer referenced. - -In `server/fresh-agent/runtime-adapter.ts`, `shared/ws-protocol.ts`, and `server/ws-handler.ts`, remove the legacy `legacyRestoreContext` field from fresh-agent create/resume input. It is currently forwarded by `ws-handler.ts`; delete that forwarding path and update `test/unit/server/ws-handler-fresh-agent.test.ts` so it no longer asserts that legacy context is accepted or forwarded. - -In `resume`, replace placeholder handling with: - -```ts -if (isPlaceholderOpencodeSessionId(sessionId)) { - throw new FreshAgentLostSessionError( - `OpenCode session ${sessionId} cannot be restored because it is not a canonical OpenCode session id.`, - ) -} -``` - -Keep `create()` placeholders for not-yet-materialized new live sessions. Keep promotion from placeholder to `ses_*` when OpenCode materializes a real session. - -In `src/components/fresh-agent/FreshAgentView.tsx`, stop sending `legacyRestoreContext` for Freshopencode restore/create. If a restored Freshopencode pane only has a `freshopencode-*` id, normalize it locally to a restore error before sending any fresh-agent create/resume message. - -Use this visible message: - -```ts -This Freshopencode pane cannot be restored because it only saved a temporary id. Start a new Freshopencode session. -``` - -Do not include the placeholder in `sessionId`, `sessionRef`, or `resumeSessionId` after this normalization; keep it only inside restore-error details if needed for debugging. - -- [ ] **Step 3: Update client visible message for Freshopencode placeholders** - -In `src/components/fresh-agent/FreshAgentView.tsx`, update the restore/load error text path so local placeholder normalization and any server `FRESH_AGENT_LOST_SESSION` for Freshopencode say: - -```ts -This Freshopencode pane cannot be restored because it only saved a temporary id. Start a new Freshopencode session. -``` - -Do not add a retry path that reuses the placeholder. - -Make the restore error message provider-aware: `invalid_legacy_restore_target` should keep the existing Claude wording for Claude, but Freshopencode placeholder normalization must render the temporary-id message above. Update all existing FreshAgentView placeholder/restore-error tests that asserted legacy-context retry behavior. - -In `shared/fresh-agent.ts`, update `migrateLegacyFreshAgentDurableState` with provider-specific OpenCode validation: - -- If `sessionRef.provider === 'opencode'` and `sessionRef.sessionId` is canonical `ses_*`, keep it. -- If `sessionRef.provider === 'opencode'` is a `freshopencode-*` placeholder and `resumeSessionId` is canonical `ses_*`, return the canonical `sessionRef`. -- If the only OpenCode identity is `freshopencode-*`, return the visible restore error and clear identity fields. -- If an OpenCode identity is neither canonical `ses_*` nor a known live placeholder, return the same visible restore error. - -In `src/store/persistedState.ts`, `src/store/storage-migration.ts`, `src/store/persistMiddleware.ts`, and `server/agent-api/layout-store.ts`, use the shared normalization path so temporary Freshopencode ids are not round-tripped as durable restore state. - -- [ ] **Step 4: Update client test** - -In `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`, update the existing placeholder restore test to assert the visible clear error and no legacy retry/resolution request. - -```ts -expect(await screen.findByText(/only saved a temporary id/i)).toBeInTheDocument() -expect(mockGetFreshAgentThreadSnapshot).not.toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - 'freshopencode-old-placeholder', - expect.anything(), -) -``` - -- [ ] **Step 5: Run focused OpenCode/error tests** - -Run: `env -u NODE_ENV -u INIT_CWD npm run test:vitest -- test/unit/server/fresh-agent/opencode-serve-adapter.test.ts test/unit/server/ws-handler-fresh-agent.test.ts test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistedState.test.ts test/unit/server/agent-api/layout-store-fresh-agent.test.ts --run` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add server/fresh-agent/adapters/opencode/adapter.ts server/fresh-agent/runtime-adapter.ts server/ws-handler.ts shared/ws-protocol.ts shared/fresh-agent.ts src/components/fresh-agent/FreshAgentView.tsx src/store/persistedState.ts src/store/storage-migration.ts src/store/persistMiddleware.ts server/agent-api/layout-store.ts test/unit/server/fresh-agent/opencode-serve-adapter.test.ts test/unit/server/ws-handler-fresh-agent.test.ts test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistedState.test.ts test/unit/server/agent-api/layout-store-fresh-agent.test.ts -git commit -m "feat: fail freshopencode legacy restore clearly" -``` - ---- - -### Task 5: Integration Verification and Cleanup - -**Files:** -- Modify only files needed to fix review/test findings from Tasks 1-4. -- Test: focused tests from Tasks 1-4. -- Test: `npm run check` - -**Interfaces:** -- Consumes: all prior tasks. -- Produces: branch with green focused tests, green typecheck, and a clear implementation summary. - -- [ ] **Step 1: Run all focused tests from this plan** - -Run: - -```bash -env -u NODE_ENV -u INIT_CWD npm run test:vitest -- \ - test/unit/server/fresh-agent/router.test.ts \ - test/unit/server/fresh-agent/claude-history-service.test.ts \ - test/unit/server/fresh-agent/claude-adapter.test.ts \ - test/unit/server/fresh-agent/codex-adapter.test.ts \ - test/unit/server/fresh-agent/opencode-serve-adapter.test.ts \ - test/unit/server/ws-handler-fresh-agent.test.ts \ - test/unit/shared/fresh-agent-turns.test.ts \ - test/unit/client/store/freshAgentSlice.test.ts \ - test/unit/client/lib/api.test.ts \ - test/unit/client/lib/fresh-agent-ws.test.ts \ - test/unit/client/store/persistedState.test.ts \ - test/unit/server/agent-api/layout-store-fresh-agent.test.ts \ - test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx \ - test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ - --run -``` - -Expected: PASS. - -- [ ] **Step 2: Run coordinated check** - -Run: `env -u NODE_ENV -u INIT_CWD FRESHELL_TEST_SUMMARY='fresh-agent progressive hydration final check' npm run check` - -Expected: typecheck PASS and coordinated test suite PASS. - -- [ ] **Step 3: Inspect diff for accidental CLI/terminal behavior changes** - -Run: - -```bash -git diff --stat origin/main...HEAD -git diff --name-only origin/main...HEAD -``` - -Expected: changed files are fresh-agent contracts, fresh-agent server adapters/routes, fresh-agent store/UI, and related tests only. No terminal replay, terminal spawning, Codex app-server protocol-wrapper, or user-visible CLI mode behavior files should be changed for this feature. - -Also run: - -```bash -git diff origin/main...HEAD -- server/coding-cli src/components/TerminalView.tsx shared/ws-protocol.ts -``` - -Expected: no terminal/CLI behavior changes and no `server/coding-cli` changes. A `shared/ws-protocol.ts` change is acceptable only if it removes unused Freshopencode legacy restore context from the fresh-agent message surface. - -- [ ] **Step 4: Commit final cleanup if needed** - -If Step 1 or Step 2 required fixes: - -```bash -git add -git commit -m "fix: stabilize fresh-agent progressive hydration" -``` - -If no fixes were needed, do not create an empty commit. - -- [ ] **Step 5: Final branch review prep** - -Run: - -```bash -git log --oneline origin/main..HEAD -git status --short --branch -``` - -Expected: focused commits exist, worktree is clean, branch is still `feature/fresh-agent-progressive-hydration`. - ---- - -## Self-Review - -**Spec coverage:** The plan covers fresh-agent-only behavior, newest-page-first restore, canonical provider adapters, clear Freshopencode errors, no CLI behavior changes, and no hidden fallback path. - -**Placeholder scan:** The plan contains no TBD/TODO placeholders. Every task names files, commands, expected outcomes, and concrete code shapes. - -**Type consistency:** The same terms are used throughout: `historyItems`, `nextHistoryCursor`, `historyRevision`, `loadFreshAgentThreadTurns`, `getFreshAgentTurnPage`, and `FreshAgentLostSessionError`. diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index 1baf1593a..284410cb2 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -84,8 +84,6 @@ function freshAgentErrorStatus(error: unknown): number { } const FRESH_AGENT_SEND_IDLE_TIMEOUT_MS = 600_000 -const FRESH_AGENT_CAPTURE_TURN_LIMIT = 200 -const FRESH_AGENT_CAPTURE_MAX_PAGES = 1000 async function waitForFreshAgentIdle( runtimeManager: NonNullable, @@ -321,8 +319,8 @@ function terminalInputFailureMessage(result: Exclude { const role = typeof turn?.role === 'string' ? turn.role : 'turn' const items = Array.isArray(turn?.items) ? turn.items : [] @@ -339,48 +337,6 @@ function renderFreshAgentTranscript(input: any): string { }).join('\n\n') } -async function captureFreshAgentTranscript( - runtimeManager: NonNullable, - input: FreshAgentThreadLocator, -): Promise<{ turns: any[] }> { - if (!runtimeManager.getTurnPage) { - throw new Error('fresh-agent transcript paging not available on this server') - } - const pages: any[][] = [] - const seenCursors = new Set() - let cursor: string | undefined - let revision: number | undefined - - for (let pageIndex = 0; pageIndex < FRESH_AGENT_CAPTURE_MAX_PAGES; pageIndex += 1) { - const page = await runtimeManager.getTurnPage({ - ...input, - ...(cursor ? { cursor } : {}), - ...(revision !== undefined ? { revision } : {}), - limit: FRESH_AGENT_CAPTURE_TURN_LIMIT, - includeBodies: true, - priority: 'visible', - }) - pages.push(Array.isArray(page?.turns) ? page.turns : []) - if (revision === undefined && typeof page?.revision === 'number' && Number.isFinite(page.revision)) { - revision = page.revision - } - - const nextCursor = typeof page?.nextCursor === 'string' && page.nextCursor.length > 0 - ? page.nextCursor - : undefined - if (!nextCursor) { - return { turns: pages.reverse().flat() } - } - if (seenCursors.has(nextCursor)) { - throw new Error('fresh-agent transcript paging returned a repeated cursor') - } - seenCursors.add(nextCursor) - cursor = nextCursor - } - - throw new Error(`fresh-agent transcript exceeded ${FRESH_AGENT_CAPTURE_MAX_PAGES} pages`) -} - function shouldWaitForCodexIdentity(payload: Record): boolean { return truthy(payload.waitForCodexIdentity) } @@ -506,7 +462,6 @@ type FreshAgentRuntimeManagerLike = { send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; submittedTurnId?: string; sessionRef?: { provider: string; sessionId: string } } | void> attach: (locator: FreshAgentSessionLocator) => Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> getSnapshot: (input: FreshAgentThreadLocator) => Promise - getTurnPage?: (input: FreshAgentThreadLocator & { cursor?: string; revision?: number; limit?: number; includeBodies?: boolean; priority?: string }) => Promise } const parseRegex = (raw: string) => { @@ -946,15 +901,14 @@ export function createAgentApiRouter({ if (paneSnapshot?.kind === 'fresh-agent') { const c = paneSnapshot.paneContent || {} if (!freshAgentRuntimeManager) return res.status(503).json(fail('fresh-agent runtime not available on this server')) - if (!freshAgentRuntimeManager.getTurnPage) return res.status(503).json(fail('fresh-agent transcript paging not available on this server')) try { - const transcript = await captureFreshAgentTranscript(freshAgentRuntimeManager, { + const snapshot = await freshAgentRuntimeManager.getSnapshot({ sessionType: c.sessionType, provider: c.provider, threadId: c.sessionId, cwd: freshAgentPaneCwd(c), }) - return res.type('text/plain').send(renderFreshAgentTranscript(transcript)) + return res.type('text/plain').send(renderFreshAgentTranscript(snapshot)) } catch (err: any) { return res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'fresh-agent capture failed')) } diff --git a/server/fresh-agent/adapters/claude/adapter.ts b/server/fresh-agent/adapters/claude/adapter.ts index 3c46b114f..525e94dfd 100644 --- a/server/fresh-agent/adapters/claude/adapter.ts +++ b/server/fresh-agent/adapters/claude/adapter.ts @@ -253,7 +253,7 @@ export function createClaudeFreshAgentAdapter(deps: ClaudeFreshAgentAdapterDeps) sessionId: thread.threadId, cursor: typeof query.cursor === 'string' ? query.cursor : undefined, priority: typeof query.priority === 'string' ? query.priority as 'visible' | 'background' : undefined, - revision: typeof query.revision === 'number' ? query.revision : undefined, + revision: Number(query.revision), limit: typeof query.limit === 'number' ? query.limit : undefined, includeBodies: query.includeBodies === true, }) diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts index c5867a6b4..be7f75315 100644 --- a/server/fresh-agent/adapters/codex/adapter.ts +++ b/server/fresh-agent/adapters/codex/adapter.ts @@ -71,7 +71,6 @@ type CodexRuntimePort = { cursor?: string limit?: number itemsView?: 'notLoaded' | 'summary' | 'full' - sortDirection?: 'asc' | 'desc' }) => Promise> readThreadTurn: (input: { threadId: string; turnId: string; revision?: number }) => Promise> } @@ -264,22 +263,7 @@ function isCodexIncludeTurnsUnavailable(error: unknown): boolean { || error.message.includes('not materialized yet') } -function nonEmptyString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - function findActiveTurnId(rawSnapshot: Record): string | undefined { - const thread = rawSnapshot.thread && typeof rawSnapshot.thread === 'object' && !Array.isArray(rawSnapshot.thread) - ? rawSnapshot.thread as Record - : {} - const status = thread.status && typeof thread.status === 'object' && !Array.isArray(thread.status) - ? thread.status as Record - : {} - const metadataTurnId = nonEmptyString(status.activeTurnId) - ?? nonEmptyString(status.turnId) - ?? nonEmptyString(thread.activeTurnId) - if (metadataTurnId) return metadataTurnId - const turns = Array.isArray(rawSnapshot.thread?.turns) ? rawSnapshot.thread.turns : [] for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index] @@ -541,33 +525,18 @@ export function createCodexFreshAgentAdapter(deps: { let providerCursor: string | null | undefined let backwardsCursor: string | null | undefined - const finishPage = (nextCursor: string | null, pageBackwardsCursor: string | null) => { - const readingOrderTurns = turns.slice().reverse() - return FreshAgentTurnPageSchema.parse({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: input.threadId, - revision: input.revision, - nextCursor, - backwardsCursor: pageBackwardsCursor, - turns: readingOrderTurns.map((turn, index) => ({ ...turn, ordinal: index })), - bodies: Object.fromEntries(readingOrderTurns.map((turn) => [turn.turnId, turn])), - }) - } - const appendTurnRows = (rawTurn: Record, offset: number, providerCursorAfterTurn: string | null) => { const normalized = normalizeSingleRawTurn({ threadId: input.threadId, revision: input.revision, rawTurn, }) - const newestFirstRows = normalized.turns.slice().reverse() - const availableRows = newestFirstRows.slice(offset) + const availableRows = normalized.turns.slice(offset) const remainingSlots = limit - turns.length const selectedRows = availableRows.slice(0, remainingSlots) turns.push(...selectedRows) const nextDisplayOffset = offset + selectedRows.length - if (nextDisplayOffset < newestFirstRows.length) { + if (nextDisplayOffset < normalized.turns.length) { return createDisplayCursor({ threadId: input.threadId, revision: input.revision, @@ -600,7 +569,16 @@ export function createCodexFreshAgentAdapter(deps: { if (cursor.rawTurn) { const nextCursor = appendTurnRows(cursor.rawTurn, cursor.nextDisplayOffset, cursor.providerCursor) if (turns.length >= limit || nextCursor || !cursor.providerCursor) { - return finishPage(nextCursor, null) + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor, + backwardsCursor: null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) } } } @@ -611,7 +589,6 @@ export function createCodexFreshAgentAdapter(deps: { ...(providerCursor ? { cursor: providerCursor } : {}), limit: 1, itemsView: 'full', - sortDirection: 'desc', }) const pageRevision = Number(rawPage.revision ?? input.revision) if (pageRevision !== input.revision) { @@ -628,12 +605,30 @@ export function createCodexFreshAgentAdapter(deps: { } const nextCursor = appendTurnRows(rawTurns[0], 0, providerCursor) if (turns.length >= limit || nextCursor) { - return finishPage(nextCursor, backwardsCursor ?? null) + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor, + backwardsCursor: backwardsCursor ?? null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) } if (!providerCursor) break } - return finishPage(null, backwardsCursor ?? null) + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor: null, + backwardsCursor: backwardsCursor ?? null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) } const findDisplayIndexEntry = (threadId: string, revision: number, displayTurnId: string): DisplayIndexEntry | undefined => { @@ -653,7 +648,6 @@ export function createCodexFreshAgentAdapter(deps: { ...(cursor ? { cursor } : {}), limit: 100, itemsView: 'full', - sortDirection: 'desc', }) const currentRevision = Number(rawPage.revision ?? revision) normalizeRawPage({ threadId, revision: currentRevision, rawPage }) @@ -1031,23 +1025,38 @@ export function createCodexFreshAgentAdapter(deps: { thread.threadId, settingsFromLocator(thread) ?? settingsByThread.get(thread.threadId), ) - const rawSnapshot = await runtime.readThread({ threadId: thread.threadId, includeTurns: false }) + let rawSnapshot: Record + try { + rawSnapshot = await runtime.readThread({ threadId: thread.threadId, includeTurns: true }) + } catch (error) { + if (!isCodexIncludeTurnsUnavailable(error)) { + throw error + } + rawSnapshot = await runtime.readThread({ threadId: thread.threadId, includeTurns: false }) + } + const rawThreadTurns: unknown[] = Array.isArray(rawSnapshot.thread?.turns) + ? rawSnapshot.thread.turns + : [] const activeTurnId = findActiveTurnId(rawSnapshot) if (activeTurnId) { activeTurnByThread.set(thread.threadId, activeTurnId) } else if (normalizeCodexThreadStatus(rawSnapshot.thread?.status) !== 'running') { activeTurnByThread.delete(thread.threadId) } + const rawTurns = rawThreadTurns + .filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn)) const revisionNumber = Number(rawSnapshot.thread?.updatedAt ?? revision ?? 0) - if (!Number.isFinite(revisionNumber)) { - throw new FreshAgentUnprovableThreadRevisionError(0) - } + const turns = normalizeRawTurns({ + threadId: thread.threadId, + revision: revisionNumber, + rawTurns, + }) return normalizeCodexThreadSnapshot({ threadId: thread.threadId, revision: revisionNumber, status: normalizeCodexThreadStatus(rawSnapshot.thread?.status), transcript: { - turns: [], + turns, }, rawSnapshot, }) @@ -1058,22 +1067,10 @@ export function createCodexFreshAgentAdapter(deps: { thread.threadId, settingsFromLocator(thread) ?? settingsByThread.get(thread.threadId), ) - let revision = typeof query.revision === 'number' ? query.revision : undefined - if (revision == null && typeof query.cursor !== 'string') { - const metadata = await runtime.readThread({ threadId: thread.threadId, includeTurns: false }) - const metadataRevision = Number(metadata.thread?.updatedAt) - if (!Number.isFinite(metadataRevision)) { - throw new FreshAgentUnprovableThreadRevisionError(0) - } - revision = metadataRevision - } - if (revision == null) { - throw new FreshAgentUnprovableThreadRevisionError(0) - } return normalizeDisplayTurnPage({ runtime, threadId: thread.threadId, - revision, + revision: Number(query.revision ?? 0), cursor: typeof query.cursor === 'string' ? query.cursor : undefined, limit: typeof query.limit === 'number' ? query.limit : undefined, }) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index f2a55dc92..ab07fe568 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -1,14 +1,16 @@ import { EventEmitter } from 'node:events' import { stat } from 'node:fs/promises' +import path from 'node:path' import type { FreshAgentCreateRequest, FreshAgentRuntimeAdapter, FreshAgentSendResult, FreshAgentThreadLocator, } from '../../runtime-adapter.js' -import { FreshAgentLostSessionError, FreshAgentStaleThreadRevisionError } from '../../runtime-manager.js' +import { FreshAgentLostSessionError } from '../../runtime-manager.js' import { normalizeFreshAgentEffort, normalizeFreshAgentModel } from '../../../../shared/fresh-agent-models.js' import { logger } from '../../../logger.js' +import { defaultOpencodeDataHome } from '../../../coding-cli/providers/opencode.js' import { hashForLogs, recordFreshAgentObservabilityEvent, @@ -19,12 +21,16 @@ import { normalizeOpencodeTurnBody, normalizeOpencodeTurnPage, } from './normalize.js' +import { DEFAULT_SNAPSHOT_TURN_LIMIT } from './history-query.js' +import { + createWorkerHistoryReader, + type OpencodeHistoryReader, +} from './history-runner.js' import type { OpencodeServeManager } from './serve-manager.js' import { serveEventToSdk, splitOpencodeModel } from './serve-events.js' const OPENCODE_REAL_SESSION_ID = /^ses_/ const OPENCODE_PLACEHOLDER_SESSION_ID = /^freshopencode-/ -const DEFAULT_SNAPSHOT_TURN_LIMIT = 200 const DEFAULT_TURN_TIMEOUT_MS = 600_000 type OpencodeSessionState = { @@ -41,6 +47,10 @@ type OpencodeSessionState = { type CreateOpencodeFreshAgentAdapterOptions = { serveManager: OpencodeServeManager + /** Retained ONLY for legacy `freshopencode-*` placeholder resume. */ + historyReader?: OpencodeHistoryReader + dbPath?: string + dataHome?: string turnTimeoutMs?: number validateCwd?: (cwd: string) => Promise } @@ -67,6 +77,13 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const serveManager = options.serveManager const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS const validateCwd = options.validateCwd ?? defaultValidateCwd + 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 + const legacyReader = (): OpencodeHistoryReader => { + if (!historyReader) historyReader = createWorkerHistoryReader({ dbPath }) + return historyReader + } const log = logger.child({ component: 'freshopencode-serve-adapter' }) const sessions = new Map() @@ -283,7 +300,14 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const sessionId = normalized.resumeSessionId if (!sessionId) throw new Error('OpenCode resume requires a session id.') if (isPlaceholderOpencodeSessionId(sessionId)) { - throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} is a temporary Freshopencode id. Restore requires a durable OpenCode session id that starts with ses_.`) + const real = await resolveLegacyPlaceholder(legacyReader(), normalized, sessionId) + const state: OpencodeSessionState = { + placeholderId: sessionId, realSessionId: real, cwd: normalized.cwd, model: normalized.model, + effort: normalized.effort, status: 'idle', events: new EventEmitter(), sendQueue: Promise.resolve(), + } + remember(state) + bindServeStream(state) + return { sessionId: real, sessionRef: { provider: 'opencode', sessionId: real } } } if (!isRealOpencodeSessionId(sessionId)) { throw new FreshAgentLostSessionError(`OpenCode session ${sessionId} is not a durable OpenCode session.`) @@ -417,16 +441,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen const { exported, nextCursor, revision } = route ? await assembleExport(realId, pageQuery, route) : await assembleExport(realId, pageQuery) - if (typeof query.revision === 'number' && query.revision !== revision) { - throw new FreshAgentStaleThreadRevisionError(revision) - } - return normalizeOpencodeTurnPage({ - threadId: thread.threadId, - exported, - revision, - nextCursor, - includeBodies: query.includeBodies === true, - }) + return normalizeOpencodeTurnPage({ threadId: thread.threadId, exported, revision, nextCursor }) }, async getTurnBody(thread, revision) { @@ -448,3 +463,23 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen }, } } + +async function resolveLegacyPlaceholder(reader: OpencodeHistoryReader, input: FreshAgentCreateRequest, placeholderId: string): Promise { + const ctx = input.legacyRestoreContext + const title = typeof ctx?.title === 'string' ? ctx.title : undefined + const createdAt = typeof ctx?.createdAt === 'number' ? ctx.createdAt : undefined + const updatedAt = typeof ctx?.updatedAt === 'number' ? ctx.updatedAt : undefined + if (!input.cwd || (!title && createdAt === undefined && updatedAt === undefined)) { + throw new FreshAgentLostSessionError(`OpenCode session ${placeholderId} is not a durable OpenCode session.`) + } + let resolved: Awaited> + try { + resolved = await reader.resolveLegacySession({ cwd: input.cwd, title, createdAt, updatedAt }) + } catch { + throw new FreshAgentLostSessionError(`OpenCode session ${placeholderId} is not a durable OpenCode session.`) + } + if (!resolved?.id || !/^ses_/.test(resolved.id)) { + throw new FreshAgentLostSessionError(`OpenCode session ${placeholderId} is not a durable OpenCode session.`) + } + return resolved.id +} diff --git a/server/fresh-agent/adapters/opencode/history-query.ts b/server/fresh-agent/adapters/opencode/history-query.ts new file mode 100644 index 000000000..eabaef2b3 --- /dev/null +++ b/server/fresh-agent/adapters/opencode/history-query.ts @@ -0,0 +1,670 @@ +import type { OpencodeExport } from './normalize.js' + +export type OpencodeSessionInfo = NonNullable & { + id: string + directory?: string +} + +export type OpencodeHistoryPage = { + exported: OpencodeExport + revision: number + nextCursor: string | null + hasMoreBefore: boolean + totalMessages?: number +} + +export type OpencodeTurnBodyResult = { + message: NonNullable[number] + revision: number +} + +export type OpencodeLegacySessionResolveInput = { + cwd?: string + title?: string + createdAt?: number + updatedAt?: number +} + +export type OpencodeHistoryExportPage = OpencodeHistoryPage & OpencodeExport +export type OpencodeHistoryTurnBody = OpencodeTurnBodyResult & NonNullable[number] + +export type OpencodeHistoryReadRequest = + | { type: 'session_info'; sessionId: string } + | { type: 'legacy_session'; query: OpencodeLegacySessionResolveInput } + | { type: 'snapshot_page'; sessionId: string; limit?: number } + | { type: 'turn_page'; sessionId: string; query?: { cursor?: string; limit?: number } } + | { type: 'turn_body'; sessionId: string; turnId: string } + +export type OpencodeHistoryReadResult = + | { type: 'session_info'; sessionInfo: OpencodeSessionInfo } + | { type: 'legacy_session'; sessionInfo: OpencodeSessionInfo } + | { type: 'snapshot_page'; page: OpencodeHistoryExportPage } + | { type: 'turn_page'; page: OpencodeHistoryExportPage } + | { type: 'turn_body'; body: OpencodeHistoryTurnBody } + +type StatementLike = { + all: (...params: any[]) => unknown[] + get: (...params: any[]) => unknown +} + +type DatabaseLike = { + exec: (sql: string) => unknown + prepare: (sql: string) => StatementLike +} + +type SessionRow = Record & { + id?: unknown + directory?: unknown + title?: unknown + model?: unknown + cost?: unknown + tokens_input?: unknown + tokens_output?: unknown + tokens_reasoning?: unknown + tokens_cache_read?: unknown + tokens_cache_write?: unknown + time_created?: unknown + time_updated?: unknown +} + +type MessageRow = { + id: unknown + session_id: unknown + time_created: unknown + time_updated: unknown + data: unknown +} + +type PartRow = { + id: unknown + message_id: unknown + session_id: unknown + time_created: unknown + time_updated: unknown + data: unknown +} + +type HydratedMessage = NonNullable[number] + +const OPENCODE_DB_BUSY_TIMEOUT_MS = 5000 + +export const DEFAULT_SNAPSHOT_TURN_LIMIT = 200 + +const LEGACY_PLACEHOLDER_LOOKAHEAD_MS = 24 * 60 * 60_000 +const LEGACY_PLACEHOLDER_LOOKBEHIND_MS = 5 * 60_000 +const LEGACY_PLACEHOLDER_CANDIDATE_LIMIT = 50 +const LEGACY_TITLE_STOP_WORDS = new Set([ + 'the', + 'and', + 'for', + 'from', + 'into', + 'onto', + 'that', + 'this', + 'with', + 'your', + 'ours', + 'mine', + 'have', + 'has', + 'had', + 'are', + 'was', + 'were', + 'can', + 'cannot', + 'cant', + 'dont', + 'list', + 'all', + 'each', + 'one', +]) + +const SESSION_REQUIRED_COLUMNS = [ + 'id', + 'directory', + 'title', + 'model', + 'cost', + 'tokens_input', + 'tokens_output', + 'tokens_reasoning', + 'tokens_cache_read', + 'tokens_cache_write', + 'time_created', + 'time_updated', +] as const + +const SESSION_OPTIONAL_COLUMNS = [ + 'project_id', + 'workspace_id', + 'parent_id', + 'slug', + 'path', + 'version', + 'share_url', + 'summary_additions', + 'summary_deletions', + 'summary_files', + 'summary_diffs', + 'metadata', + 'revert', + 'permission', + 'agent', + 'time_compacting', + 'time_archived', +] as const + +const MESSAGE_REQUIRED_COLUMNS = [ + 'id', + 'session_id', + 'time_created', + 'time_updated', + 'data', +] as const + +const PART_REQUIRED_COLUMNS = [ + 'id', + 'message_id', + 'session_id', + 'time_created', + 'time_updated', + 'data', +] as const + +export class OpencodeHistorySchemaError extends Error { + readonly code = 'OPENCODE_HISTORY_SCHEMA_ERROR' + readonly table: string + readonly missingColumns: string[] + + constructor(table: string, missingColumns: string[]) { + super(`OpenCode history table "${table}" is missing required columns: ${missingColumns.join(', ')}`) + this.name = 'OpencodeHistorySchemaError' + this.table = table + this.missingColumns = missingColumns + } +} + +function inspectColumns(db: DatabaseLike, table: string): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }> + return new Set(rows.map((row) => row.name).filter((name): name is string => typeof name === 'string')) +} + +function requireColumns(db: DatabaseLike, table: string, required: readonly string[]): Set { + const columns = inspectColumns(db, table) + const missingColumns = required.filter((column) => !columns.has(column)) + if (missingColumns.length > 0) { + throw new OpencodeHistorySchemaError(table, missingColumns) + } + return columns +} + +function requireHistorySchema(db: DatabaseLike): { sessionColumns: Set } { + const sessionColumns = requireColumns(db, 'session', SESSION_REQUIRED_COLUMNS) + requireColumns(db, 'message', MESSAGE_REQUIRED_COLUMNS) + requireColumns(db, 'part', PART_REQUIRED_COLUMNS) + return { sessionColumns } +} + +function normalizeLimit(value: number | undefined, defaultValue: number): number { + if (!Number.isFinite(value) || value === undefined) return defaultValue + return Math.max(1, Math.min(defaultValue, Math.trunc(value))) +} + +function parseJsonText(value: unknown, label: string): unknown { + if (value === null || value === undefined) return undefined + if (typeof value !== 'string') return value + if (value.length === 0) return undefined + try { + return JSON.parse(value) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse OpenCode ${label} JSON: ${message}`) + } +} + +function parseJsonObject(value: unknown, label: string): Record { + const parsed = parseJsonText(value, label) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} +} + +function numberValue(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'bigint') return Number(value) + return undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function normalizedTitleTokens(value: unknown): Set { + const text = stringValue(value) + if (!text) return new Set() + const tokens = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 3 && !LEGACY_TITLE_STOP_WORDS.has(token)) + .map((token) => token.endsWith('s') && token.length > 4 ? token.slice(0, -1) : token) + return new Set(tokens) +} + +function titleOverlapScore(left: Set, right: Set): number { + let overlap = 0 + for (const token of left) { + if (right.has(token)) overlap += 1 + } + return overlap +} + +function hasLegacyTitleMatch(queryTokens: Set, candidateTitle: unknown): boolean { + if (queryTokens.size === 0) return true + const candidateTokens = normalizedTitleTokens(candidateTitle) + if (candidateTokens.size === 0) return false + const overlap = titleOverlapScore(queryTokens, candidateTokens) + const requiredOverlap = Math.min(2, queryTokens.size, candidateTokens.size) + return overlap >= requiredOverlap +} + +function setIfString(target: Record, key: string, value: unknown): void { + const str = stringValue(value) + if (str !== undefined) target[key] = str +} + +function setIfNumber(target: Record, key: string, value: unknown): void { + const number = numberValue(value) + if (number !== undefined) target[key] = number +} + +function sessionSelectList(columns: Set): string { + const names = [...SESSION_REQUIRED_COLUMNS, ...SESSION_OPTIONAL_COLUMNS] + return names + .map((column) => columns.has(column) ? `s.${column} AS ${column}` : `NULL AS ${column}`) + .join(', ') +} + +function readSessionRow(db: DatabaseLike, sessionId: string, columns: Set): SessionRow | undefined { + return db.prepare(` + SELECT ${sessionSelectList(columns)} + FROM session s + WHERE s.id = ? + LIMIT 1 + `).get(sessionId) as SessionRow | undefined +} + +function hydrateSessionInfo(row: SessionRow): OpencodeSessionInfo { + const info: Record = { + id: String(row.id), + tokens: { + input: numberValue(row.tokens_input) ?? 0, + output: numberValue(row.tokens_output) ?? 0, + reasoning: numberValue(row.tokens_reasoning) ?? 0, + cache: { + read: numberValue(row.tokens_cache_read) ?? 0, + write: numberValue(row.tokens_cache_write) ?? 0, + }, + }, + time: { + created: numberValue(row.time_created) ?? 0, + updated: numberValue(row.time_updated) ?? 0, + }, + } + + setIfString(info, 'directory', row.directory) + setIfString(info, 'title', row.title) + setIfString(info, 'projectID', row.project_id) + setIfString(info, 'workspaceID', row.workspace_id) + setIfString(info, 'parentID', row.parent_id) + setIfString(info, 'slug', row.slug) + setIfString(info, 'path', row.path) + setIfString(info, 'version', row.version) + setIfString(info, 'shareURL', row.share_url) + setIfString(info, 'permission', row.permission) + setIfString(info, 'agent', row.agent) + setIfNumber(info, 'cost', row.cost) + + const model = parseJsonText(row.model, 'session.model') + if (model !== undefined) info.model = model + + const metadata = parseJsonText(row.metadata, 'session.metadata') + if (metadata !== undefined) info.metadata = metadata + + const summaryDiffs = parseJsonText(row.summary_diffs, 'session.summary_diffs') + const summary = { + additions: numberValue(row.summary_additions) ?? 0, + deletions: numberValue(row.summary_deletions) ?? 0, + files: numberValue(row.summary_files) ?? 0, + ...(summaryDiffs !== undefined ? { diffs: summaryDiffs } : {}), + } + if ( + summary.additions !== 0 + || summary.deletions !== 0 + || summary.files !== 0 + || 'diffs' in summary + ) { + info.summary = summary + } + + const revert = parseJsonText(row.revert, 'session.revert') + if (revert !== undefined) info.revert = revert + + const compacting = numberValue(row.time_compacting) + const archived = numberValue(row.time_archived) + const time = info.time as Record + if (compacting !== undefined) time.compacting = compacting + if (archived !== undefined) time.archived = archived + + return info as OpencodeSessionInfo +} + +function hydrateMessage(row: MessageRow, parts: Record[]): HydratedMessage { + const data = parseJsonObject(row.data, 'message.data') + return { + info: { + ...data, + id: String(row.id), + sessionID: String(row.session_id), + time: { + created: numberValue(row.time_created) ?? 0, + updated: numberValue(row.time_updated) ?? 0, + }, + }, + parts, + } +} + +function hydratePart(row: PartRow): Record { + const data = parseJsonObject(row.data, 'part.data') + return { + ...data, + id: String(row.id), + sessionID: String(row.session_id), + messageID: String(row.message_id), + time: { + created: numberValue(row.time_created) ?? 0, + updated: numberValue(row.time_updated) ?? 0, + }, + } +} + +function readPartsByMessageId( + db: DatabaseLike, + sessionId: string, + messageIds: readonly string[], +): Map[]> { + const partsByMessageId = new Map[]>() + if (messageIds.length === 0) return partsByMessageId + const placeholders = messageIds.map(() => '?').join(', ') + const rows = db.prepare(` + SELECT id, message_id, session_id, time_created, time_updated, data + FROM part + WHERE session_id = ? + AND message_id IN (${placeholders}) + ORDER BY id ASC + `).all(sessionId, ...messageIds) as PartRow[] + for (const row of rows) { + const messageId = String(row.message_id) + const parts = partsByMessageId.get(messageId) ?? [] + parts.push(hydratePart(row)) + partsByMessageId.set(messageId, parts) + } + return partsByMessageId +} + +function hydrateMessages(db: DatabaseLike, sessionId: string, rows: MessageRow[]): HydratedMessage[] { + const messageIds = rows.map((row) => String(row.id)) + const partsByMessageId = readPartsByMessageId(db, sessionId, messageIds) + return rows.map((row) => hydrateMessage(row, partsByMessageId.get(String(row.id)) ?? [])) +} + +function countMessages(db: DatabaseLike, sessionId: string): number | undefined { + const row = db.prepare('SELECT COUNT(*) AS count FROM message WHERE session_id = ?').get(sessionId) as { count?: unknown } | undefined + return numberValue(row?.count) +} + +function revisionFromInfo(info: OpencodeSessionInfo): number { + const updated = numberValue((info.time as Record | undefined)?.updated) + return Math.max(0, Math.trunc(updated ?? 0)) +} + +function makePage( + exported: OpencodeExport, + metadata: Omit, +): OpencodeHistoryExportPage { + return { + ...exported, + exported, + ...metadata, + } +} + +function withReadTransaction(db: DatabaseLike, read: () => T): T { + db.exec('BEGIN') + try { + const result = read() + db.exec('COMMIT') + return result + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + // Ignore rollback failures and preserve the original read error. + } + throw error + } +} + +export function encodeOpencodeCursor(input: { time: number; id: string } | { timeCreated: number; id: string }): string { + const time = 'time' in input ? input.time : input.timeCreated + return Buffer.from(JSON.stringify({ time, id: input.id }), 'utf8').toString('base64url') +} + +function decodeOpencodeCursor(cursor: string): { time: number; id: string } { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { time?: unknown; timeCreated?: unknown; id?: unknown } + const time = numberValue(parsed.time) ?? numberValue(parsed.timeCreated) + if (time === undefined || typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('Invalid OpenCode history cursor.') + } + return { time, id: parsed.id } +} + +export function readOpencodeSessionInfo( + db: DatabaseLike, + input: { sessionId: string }, +): OpencodeSessionInfo | undefined { + return withReadTransaction(db, () => { + const sessionColumns = requireColumns(db, 'session', SESSION_REQUIRED_COLUMNS) + const row = readSessionRow(db, input.sessionId, sessionColumns) + return row ? hydrateSessionInfo(row) : undefined + }) +} + +export function resolveOpencodeLegacySession( + db: DatabaseLike, + input: OpencodeLegacySessionResolveInput, +): OpencodeSessionInfo | undefined { + const cwd = stringValue(input.cwd) + if (!cwd) return undefined + const createdAt = numberValue(input.createdAt) + const updatedAt = numberValue(input.updatedAt) + const titleTokens = normalizedTitleTokens(input.title) + if (createdAt === undefined && updatedAt === undefined && titleTokens.size === 0) return undefined + + return withReadTransaction(db, () => { + const sessionColumns = requireColumns(db, 'session', SESSION_REQUIRED_COLUMNS) + const filters = ['s.id LIKE ?', 's.directory = ?'] + const params: unknown[] = ['ses_%', cwd] + + if (sessionColumns.has('time_archived')) { + filters.push('s.time_archived IS NULL') + } + if (sessionColumns.has('parent_id')) { + filters.push('s.parent_id IS NULL') + } + const anchor = createdAt ?? updatedAt + if (anchor !== undefined) { + filters.push('s.time_created >= ?') + params.push(anchor - LEGACY_PLACEHOLDER_LOOKBEHIND_MS) + filters.push('s.time_created <= ?') + params.push(anchor + LEGACY_PLACEHOLDER_LOOKAHEAD_MS) + } + + const rows = db.prepare(` + SELECT ${sessionSelectList(sessionColumns)} + FROM session s + WHERE ${filters.join('\n AND ')} + ORDER BY s.time_created DESC, s.id DESC + LIMIT ? + `).all(...params, LEGACY_PLACEHOLDER_CANDIDATE_LIMIT) as SessionRow[] + + const matches = rows + .filter((row) => hasLegacyTitleMatch(titleTokens, row.title)) + .map(hydrateSessionInfo) + + return matches.length === 1 ? matches[0] : undefined + }) +} + +export function readOpencodeSnapshotPage( + db: DatabaseLike, + input: { sessionId: string; limit?: number }, +): OpencodeHistoryExportPage | undefined { + const limit = normalizeLimit(input.limit, DEFAULT_SNAPSHOT_TURN_LIMIT) + return withReadTransaction(db, () => { + const { sessionColumns } = requireHistorySchema(db) + const sessionRow = readSessionRow(db, input.sessionId, sessionColumns) + if (!sessionRow) return undefined + const info = hydrateSessionInfo(sessionRow) + const rows = db.prepare(` + SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + ORDER BY time_created DESC, id DESC + LIMIT ? + `).all(input.sessionId, limit + 1) as MessageRow[] + const pageRows = rows.slice(0, limit).reverse() + const messages = hydrateMessages(db, input.sessionId, pageRows) + const exported = { info, messages } + return makePage(exported, { + revision: revisionFromInfo(info), + nextCursor: null, + hasMoreBefore: rows.length > limit, + totalMessages: countMessages(db, input.sessionId), + }) + }) +} + +export function readOpencodeTurnPage( + db: DatabaseLike, + input: { sessionId: string; cursor?: string; limit?: number }, +): OpencodeHistoryExportPage | undefined { + const limit = normalizeLimit(input.limit, DEFAULT_SNAPSHOT_TURN_LIMIT) + const cursor = input.cursor ? decodeOpencodeCursor(input.cursor) : undefined + return withReadTransaction(db, () => { + const { sessionColumns } = requireHistorySchema(db) + const sessionRow = readSessionRow(db, input.sessionId, sessionColumns) + if (!sessionRow) return undefined + const info = hydrateSessionInfo(sessionRow) + const cursorClause = cursor + ? 'AND (time_created < ? OR (time_created = ? AND id < ?))' + : '' + const cursorParams = cursor ? [cursor.time, cursor.time, cursor.id] : [] + const rows = db.prepare(` + SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + ${cursorClause} + ORDER BY time_created DESC, id DESC + LIMIT ? + `).all(input.sessionId, ...cursorParams, limit + 1) as MessageRow[] + const pageRows = rows.slice(0, limit) + const messages = hydrateMessages(db, input.sessionId, [...pageRows].reverse()) + const oldestReturnedRow = pageRows.at(-1) + const nextCursor = rows.length > limit && oldestReturnedRow + ? encodeOpencodeCursor({ time: numberValue(oldestReturnedRow.time_created) ?? 0, id: String(oldestReturnedRow.id) }) + : null + const exported = { info, messages } + return makePage(exported, { + revision: revisionFromInfo(info), + nextCursor, + hasMoreBefore: rows.length > limit, + totalMessages: countMessages(db, input.sessionId), + }) + }) +} + +export function readOpencodeTurnBody( + db: DatabaseLike, + input: { sessionId: string; turnId: string }, +): OpencodeHistoryTurnBody | null { + return withReadTransaction(db, () => { + const { sessionColumns } = requireHistorySchema(db) + const sessionRow = readSessionRow(db, input.sessionId, sessionColumns) + if (!sessionRow) return null + const info = hydrateSessionInfo(sessionRow) + const row = db.prepare(` + SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + AND id = ? + LIMIT 1 + `).get(input.sessionId, input.turnId) as MessageRow | undefined + if (!row) return null + const message = hydrateMessages(db, input.sessionId, [row])[0] + return { + ...message, + message, + revision: revisionFromInfo(info), + } + }) +} + +export async function runOpencodeHistoryQuery( + input: { dbPath: string; request: OpencodeHistoryReadRequest }, +): Promise { + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(input.dbPath, { readOnly: true }) + try { + db.exec(`PRAGMA busy_timeout = ${OPENCODE_DB_BUSY_TIMEOUT_MS}`) + switch (input.request.type) { + case 'session_info': { + const sessionInfo = readOpencodeSessionInfo(db, { sessionId: input.request.sessionId }) + return sessionInfo ? { type: 'session_info', sessionInfo } : undefined + } + case 'legacy_session': { + const sessionInfo = resolveOpencodeLegacySession(db, input.request.query) + return sessionInfo ? { type: 'legacy_session', sessionInfo } : undefined + } + case 'snapshot_page': { + const page = readOpencodeSnapshotPage(db, { + sessionId: input.request.sessionId, + limit: input.request.limit, + }) + return page ? { type: 'snapshot_page', page } : undefined + } + case 'turn_page': { + const page = readOpencodeTurnPage(db, { + sessionId: input.request.sessionId, + cursor: input.request.query?.cursor, + limit: input.request.query?.limit, + }) + return page ? { type: 'turn_page', page } : undefined + } + case 'turn_body': { + const body = readOpencodeTurnBody(db, { + sessionId: input.request.sessionId, + turnId: input.request.turnId, + }) + return body ? { type: 'turn_body', body } : undefined + } + } + } finally { + db.close() + } +} diff --git a/server/fresh-agent/adapters/opencode/history-runner.ts b/server/fresh-agent/adapters/opencode/history-runner.ts new file mode 100644 index 000000000..46a3cc7d3 --- /dev/null +++ b/server/fresh-agent/adapters/opencode/history-runner.ts @@ -0,0 +1,255 @@ +import { Worker } from 'node:worker_threads' +import type { + OpencodeHistoryPage, + OpencodeLegacySessionResolveInput, + OpencodeHistoryReadRequest, + OpencodeHistoryReadResult, + OpencodeHistoryTurnBody, + OpencodeSessionInfo, + OpencodeTurnBodyResult, +} from './history-query.js' +import { runOpencodeHistoryQuery } from './history-query.js' +// Importing the worker module on the main thread (or a Vitest worker) is safe: +// its auto-run is sentinel-guarded, so this import never spawns/posts anything. +import { + OPENCODE_HISTORY_WORKER_KIND, + type OpencodeHistoryFailureReason, + type OpencodeHistoryWorkerResponse, + type SerializedHistoryError, +} from './history-worker.js' + +export type OpencodeHistoryReader = { + readSessionInfo(sessionId: string): Promise + resolveLegacySession(input: OpencodeLegacySessionResolveInput): Promise + readSnapshotPage(sessionId: string, limit?: number): Promise + readTurnPage(sessionId: string, query: { cursor?: string; limit?: number }): Promise + readTurnBody(sessionId: string, turnId: string): Promise +} + +type WorkerLike = { + on(event: 'message', listener: (value: unknown) => void): unknown + on(event: 'error', listener: (err: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + terminate(): Promise | void +} + +export type WorkerSpawnOptions = { workerData: unknown; execArgv: string[] } + +export type CreateWorkerHistoryReaderOptions = { + dbPath: string + /** Injectable for unit tests; default spawns a real worker_threads Worker. */ + spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike + /** Override the query-module URL (used by off-thread fixtures). */ + queryModuleUrl?: string + /** Hard timeout for a single history query. Default 15 s. */ + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 15_000 +const SELF_EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js' +const WORKER_EXECARGV = [...process.execArgv, '--disable-warning=ExperimentalWarning'] +const FAILURE_REASONS = new Set([ + 'missing_db', + 'sqlite_unavailable', + 'schema_mismatch', + 'not_found', + 'read_error', +]) + +function defaultWorkerUrl(): URL { + return new URL(`./history-worker${SELF_EXT}`, import.meta.url) +} + +function defaultQueryModuleUrl(): string { + return new URL(`./history-query${SELF_EXT}`, import.meta.url).href +} + +function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike { + return new Worker(workerUrl, options) +} + +export class OpencodeHistoryReaderError extends Error { + readonly reason: OpencodeHistoryFailureReason + readonly workerError?: SerializedHistoryError + + constructor(reason: OpencodeHistoryFailureReason, message: string, workerError?: SerializedHistoryError) { + super(message) + this.name = 'OpencodeHistoryReaderError' + this.reason = reason + this.workerError = workerError + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isReadResult(value: unknown): value is OpencodeHistoryReadResult { + if (!isObject(value) || typeof value.type !== 'string') return false + switch (value.type) { + case 'session_info': + return isObject(value.sessionInfo) + case 'legacy_session': + return isObject(value.sessionInfo) + case 'snapshot_page': + case 'turn_page': + return isObject(value.page) + case 'turn_body': + return isObject(value.body) + default: + return false + } +} + +function isSerializedError(value: unknown): value is SerializedHistoryError { + return isObject(value) + && typeof value.name === 'string' + && typeof value.message === 'string' +} + +function isOkMessage(value: unknown): value is Extract { + return isObject(value) + && value.ok === true + && isReadResult(value.result) +} + +function isErrMessage(value: unknown): value is Extract { + return isObject(value) + && value.ok === false + && typeof value.reason === 'string' + && FAILURE_REASONS.has(value.reason as OpencodeHistoryFailureReason) + && (value.error === undefined || isSerializedError(value.error)) +} + +function resultForType( + result: OpencodeHistoryReadResult | undefined, + type: T, +): Extract | undefined { + if (!result) return undefined + if (result.type !== type) { + throw new Error(`OpenCode history worker returned ${result.type} for ${type} request`) + } + return result as Extract +} + +export function createWorkerHistoryReader( + options: CreateWorkerHistoryReaderOptions, +): OpencodeHistoryReader { + const spawn = options.spawn ?? defaultSpawn + const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const workerUrl = defaultWorkerUrl() + const dbPath = options.dbPath + + function run(request: OpencodeHistoryReadRequest): Promise { + return new Promise((resolve, reject) => { + const worker = spawn(workerUrl, { + workerData: { + kind: OPENCODE_HISTORY_WORKER_KIND, + queryModuleUrl, + dbPath, + request, + }, + execArgv: WORKER_EXECARGV, + }) + let settled = false + let timer: NodeJS.Timeout | undefined + + const cleanup = () => { + if (timer) clearTimeout(timer) + try { void worker.terminate() } catch { /* ignore */ } + } + const settleResolve = (result: OpencodeHistoryReadResult | undefined) => { + if (settled) return + settled = true + cleanup() + resolve(result) + } + const settleReject = (error: Error) => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + timer = setTimeout( + () => settleReject(new OpencodeHistoryReaderError( + 'read_error', + `OpenCode history worker timed out after ${timeoutMs}ms`, + )), + timeoutMs, + ) + if (typeof timer.unref === 'function') timer.unref() + + worker.on('message', (value: unknown) => { + if (isOkMessage(value)) { + settleResolve(value.result) + return + } + if (isErrMessage(value)) { + if (value.reason === 'not_found') { + settleResolve(undefined) + return + } + settleReject(new OpencodeHistoryReaderError( + value.reason, + value.error?.message || `OpenCode history worker failed: ${value.reason}`, + value.error, + )) + return + } + settleReject(new Error('OpenCode history worker sent a malformed message')) + }) + worker.on('error', (err: Error) => settleReject(err)) + worker.on('exit', (code: number) => settleReject(new Error(`OpenCode history worker exited (code ${code}) before responding`))) + }) + } + + return { + async readSessionInfo(sessionId) { + return resultForType(await run({ type: 'session_info', sessionId }), 'session_info')?.sessionInfo + }, + async resolveLegacySession(input) { + return resultForType(await run({ type: 'legacy_session', query: input }), 'legacy_session')?.sessionInfo + }, + async readSnapshotPage(sessionId, limit) { + return resultForType(await run({ type: 'snapshot_page', sessionId, limit }), 'snapshot_page')?.page + }, + async readTurnPage(sessionId, query) { + return resultForType(await run({ type: 'turn_page', sessionId, query }), 'turn_page')?.page + }, + async readTurnBody(sessionId, turnId) { + return resultForType(await run({ type: 'turn_body', sessionId, turnId }), 'turn_body')?.body + }, + } +} + +export const createWorkerOpencodeHistoryReader = createWorkerHistoryReader + +/** Runs history queries on the caller's thread. Intended for tests and fallbacks. */ +export function createInProcessHistoryReader(options: { dbPath: string }): OpencodeHistoryReader { + async function run(request: OpencodeHistoryReadRequest): Promise { + return runOpencodeHistoryQuery({ dbPath: options.dbPath, request }) + } + + return { + async readSessionInfo(sessionId) { + return resultForType(await run({ type: 'session_info', sessionId }), 'session_info')?.sessionInfo + }, + async resolveLegacySession(input) { + return resultForType(await run({ type: 'legacy_session', query: input }), 'legacy_session')?.sessionInfo + }, + async readSnapshotPage(sessionId, limit) { + return resultForType(await run({ type: 'snapshot_page', sessionId, limit }), 'snapshot_page')?.page + }, + async readTurnPage(sessionId, query) { + return resultForType(await run({ type: 'turn_page', sessionId, query }), 'turn_page')?.page + }, + async readTurnBody(sessionId, turnId) { + const body = resultForType(await run({ type: 'turn_body', sessionId, turnId }), 'turn_body')?.body + return body ? { message: body.message, revision: body.revision } as OpencodeTurnBodyResult & OpencodeHistoryTurnBody : undefined + }, + } +} + +export const createInProcessOpencodeHistoryReader = createInProcessHistoryReader diff --git a/server/fresh-agent/adapters/opencode/history-worker.ts b/server/fresh-agent/adapters/opencode/history-worker.ts new file mode 100644 index 000000000..e4b70fb26 --- /dev/null +++ b/server/fresh-agent/adapters/opencode/history-worker.ts @@ -0,0 +1,125 @@ +import fsp from 'node:fs/promises' +import { parentPort, workerData } from 'node:worker_threads' +import type { + OpencodeHistoryReadRequest, + OpencodeHistoryReadResult, + OpencodeHistorySchemaError, +} from './history-query.js' + +export const OPENCODE_HISTORY_WORKER_KIND = 'opencode-history-worker' + +export type OpencodeHistoryFailureReason = + | 'missing_db' + | 'sqlite_unavailable' + | 'schema_mismatch' + | 'not_found' + | 'read_error' + +export type WorkerHistoryInput = { + kind: typeof OPENCODE_HISTORY_WORKER_KIND + queryModuleUrl: string + dbPath: string + request: OpencodeHistoryReadRequest +} + +export type SerializedHistoryError = { + name: string + message: string + code?: string + table?: string + missingColumns?: string[] +} + +export type OpencodeHistoryWorkerResponse = + | { ok: true; result: OpencodeHistoryReadResult } + | { ok: false; reason: OpencodeHistoryFailureReason; error?: SerializedHistoryError } + +function serializeError(error: unknown): SerializedHistoryError { + if (!(error instanceof Error)) { + return { name: 'Error', message: String(error) } + } + const details = error as Partial & { code?: unknown } + return { + name: error.name, + message: error.message, + ...(typeof details.code === 'string' ? { code: details.code } : {}), + ...(typeof details.table === 'string' ? { table: details.table } : {}), + ...(Array.isArray(details.missingColumns) ? { missingColumns: details.missingColumns } : {}), + } +} + +function isSqliteUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const code = (error as { code?: unknown }).code + return code === 'ERR_UNKNOWN_BUILTIN_MODULE' + || error.message.includes('node:sqlite') + || error.message.includes('No such built-in module') +} + +function classifyError(error: unknown): OpencodeHistoryFailureReason { + if (isSqliteUnavailableError(error)) return 'sqlite_unavailable' + if ( + error instanceof Error + && ( + error.name === 'OpencodeHistorySchemaError' + || (error as { code?: unknown }).code === 'OPENCODE_HISTORY_SCHEMA_ERROR' + ) + ) { + return 'schema_mismatch' + } + return 'read_error' +} + +async function databaseExists(dbPath: string): Promise { + try { + await fsp.access(dbPath) + return true + } catch { + return false + } +} + +/** + * Run the selected history query by dynamically importing the exact resolved + * query-module URL (.ts in dev/test, .js in prod) supplied by the spawning code. + */ +export async function executeHistory(input: { + queryModuleUrl: string + dbPath: string + request: OpencodeHistoryReadRequest +}): Promise { + if (!await databaseExists(input.dbPath)) { + return { ok: false, reason: 'missing_db' } + } + + try { + const mod = await import(input.queryModuleUrl) as typeof import('./history-query.js') + const result = await mod.runOpencodeHistoryQuery({ + dbPath: input.dbPath, + request: input.request, + }) + if (!result) return { ok: false, reason: 'not_found' } + return { ok: true, result } + } catch (error) { + return { + ok: false, + reason: classifyError(error), + error: serializeError(error), + } + } +} + +// Auto-run ONLY when spawned by our runner. Vitest server tests run inside worker +// threads, so importing this module must not post to Vitest's parent port. +if (parentPort && (workerData as Partial | undefined)?.kind === OPENCODE_HISTORY_WORKER_KIND) { + const port = parentPort + executeHistory(workerData as WorkerHistoryInput) + .then((response) => port.postMessage(response)) + .catch((error: unknown) => { + port.postMessage({ + ok: false, + reason: classifyError(error), + error: serializeError(error), + } satisfies OpencodeHistoryWorkerResponse) + }) +} diff --git a/server/fresh-agent/adapters/opencode/normalize.ts b/server/fresh-agent/adapters/opencode/normalize.ts index 254d513af..bf03f5733 100644 --- a/server/fresh-agent/adapters/opencode/normalize.ts +++ b/server/fresh-agent/adapters/opencode/normalize.ts @@ -409,23 +409,20 @@ export function normalizeOpencodeTurnPage(input: { exported?: OpencodeExportWithPageMetadata revision: number nextCursor?: string | null - includeBodies?: boolean }): FreshAgentTurnPage { const messages = Array.isArray(input.exported?.messages) ? input.exported.messages : [] const nextCursor = Object.prototype.hasOwnProperty.call(input, 'nextCursor') ? input.nextCursor : input.exported?.nextCursor - const turns = messages - .map((message, index) => normalizeOpencodeTurn(message, index)) - .filter((turn): turn is FreshAgentTurn => Boolean(turn)) return { sessionType: 'freshopencode', provider: 'opencode', threadId: input.threadId, revision: input.revision, nextCursor: typeof nextCursor === 'string' ? nextCursor : null, - turns, - ...(input.includeBodies ? { bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])) } : {}), + turns: messages + .map((message, index) => normalizeOpencodeTurn(message, index)) + .filter((turn): turn is FreshAgentTurn => Boolean(turn)), } } diff --git a/server/fresh-agent/history/claude/history-service.ts b/server/fresh-agent/history/claude/history-service.ts index 2b6fca5e1..b4d209cb6 100644 --- a/server/fresh-agent/history/claude/history-service.ts +++ b/server/fresh-agent/history/claude/history-service.ts @@ -176,22 +176,21 @@ export function createClaudeFreshAgentHistoryService( async getThreadTurnPage(query) { throwIfAborted(query.signal) + if (query.revision == null) { + throw new Error('Restore revision is required') + } const limit = Math.min(query.limit ?? DEFAULT_THREAD_TURN_LIMIT, MAX_THREAD_TURN_LIMIT) const cursor = query.cursor ? decodeCursor(query.cursor) : null - if (cursor && query.revision == null) { - throw new Error('Restore revision is required when cursor is provided') - } const offset = cursor?.offset ?? 0 const history = await loadHistoryRecords(query.sessionId) throwIfAborted(query.signal) - const requestedRevision = query.revision ?? history.revision - if (requestedRevision !== history.revision) { - throw new ClaudeFreshAgentStaleHistoryRevisionError(requestedRevision, history.revision) + if (query.revision !== history.revision) { + throw new ClaudeFreshAgentStaleHistoryRevisionError(query.revision, history.revision) } if (cursor && cursor.revision !== history.revision) { throw new ClaudeFreshAgentStaleHistoryRevisionError(cursor.revision, history.revision) } - const pageItems = history.records.slice(offset, offset + limit).reverse() + const pageItems = history.records.slice(offset, offset + limit) const nextOffset = offset + pageItems.length const result: ClaudeFreshAgentHistoryPage = { diff --git a/server/fresh-agent/runtime-adapter.ts b/server/fresh-agent/runtime-adapter.ts index 8f50d8a27..87e070103 100644 --- a/server/fresh-agent/runtime-adapter.ts +++ b/server/fresh-agent/runtime-adapter.ts @@ -6,6 +6,11 @@ export type FreshAgentCreateRequest = { sessionType: FreshAgentSessionType provider?: FreshAgentRuntimeProvider cwd?: string + legacyRestoreContext?: { + title?: string + createdAt?: number + updatedAt?: number + } resumeSessionId?: string sessionRef?: { provider: string; sessionId: string } model?: string diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index d4015626f..d8684ac10 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -309,7 +309,7 @@ export class FreshAgentRuntimeManager { cwd?: string cursor?: string priority?: string - revision?: number + revision: number limit?: number includeBodies?: boolean }) { diff --git a/server/ws-handler.ts b/server/ws-handler.ts index abfcaecae..f2be17984 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -3138,6 +3138,7 @@ export class WsHandler { sessionType: m.sessionType, provider: m.provider, cwd: m.cwd, + legacyRestoreContext: m.legacyRestoreContext, resumeSessionId: m.resumeSessionId, sessionRef: m.sessionRef, model: m.model, diff --git a/shared/fresh-agent-turns.ts b/shared/fresh-agent-turns.ts index ee3677ff5..957a7183f 100644 --- a/shared/fresh-agent-turns.ts +++ b/shared/fresh-agent-turns.ts @@ -4,29 +4,6 @@ export function getFreshAgentDisplayTurnKey(turn: Pick): string[] { - const keys = new Set() - for (const candidate of [turn.turnId, turn.id]) { - if (candidate && !isTemporaryFreshAgentTurnId(candidate)) { - keys.add(`turn:${candidate}`) - } - } - if (turn.messageId) keys.add(`message:${turn.messageId}`) - return [...keys] -} - -export function freshAgentTurnsReferToSameDisplayTurn(a: FreshAgentTurn, b: FreshAgentTurn): boolean { - const aKeys = new Set(getFreshAgentTurnIdentityKeys(a)) - return getFreshAgentTurnIdentityKeys(b).some((key) => aKeys.has(key)) -} - export function freshAgentTurnText(turn: Pick): string { const textItems = turn.items .filter((item): item is Extract => item.kind === 'text') diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts index 6bd83835d..81d899f54 100644 --- a/shared/fresh-agent.ts +++ b/shared/fresh-agent.ts @@ -5,7 +5,6 @@ import { type RestoreError, type SessionRef, } from './session-contract.js' -import { isDurableProviderSessionId } from './session-flavor.js' export type FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode' @@ -161,20 +160,6 @@ export function migrateLegacyFreshAgentDurableState({ ) { return { restoreError: buildRestoreError('invalid_legacy_restore_target') } } - if ( - explicitSessionRef.provider === 'opencode' - && !isDurableProviderSessionId('opencode', explicitSessionRef.sessionId) - ) { - if (provider === 'opencode' && isDurableProviderSessionId('opencode', resumeSessionId)) { - return { - sessionRef: { - provider: 'opencode', - sessionId: resumeSessionId!, - }, - } - } - return { restoreError: buildRestoreError('invalid_legacy_restore_target') } - } return { sessionRef: explicitSessionRef } } @@ -194,18 +179,6 @@ export function migrateLegacyFreshAgentDurableState({ return { restoreError: buildRestoreError('invalid_legacy_restore_target') } } - if (provider === 'opencode') { - if (isDurableProviderSessionId(provider, resumeSessionId)) { - return { - sessionRef: { - provider, - sessionId: resumeSessionId!, - }, - } - } - return { restoreError: buildRestoreError('invalid_legacy_restore_target') } - } - return { sessionRef: { provider, @@ -260,7 +233,6 @@ export function migrateLegacyFreshAgentContent v === 'true'), ]).optional(), -}).superRefine((value, ctx) => { - if (value.cursor && value.revision == null) { - ctx.addIssue({ - code: 'custom', - path: ['revision'], - message: 'revision is required when cursor is provided', - }) - } }) export const FreshAgentThreadTurnBodyQuerySchema = z.object({ diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index e3729e4c2..0b7df212f 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -404,6 +404,11 @@ export const FreshAgentCreateSchema = z.object({ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']).optional(), cwd: z.string().optional(), + legacyRestoreContext: z.object({ + title: z.string().min(1).optional(), + createdAt: z.number().finite().optional(), + updatedAt: z.number().finite().optional(), + }).optional(), resumeSessionId: z.string().optional(), model: z.string().optional(), permissionMode: z.string().optional(), diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx index e23f6f16a..23d5a1c8a 100644 --- a/src/components/fresh-agent/FreshAgentTranscript.tsx +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react' import SlotReel from '@/components/fresh-agent/shared/SlotReel' import { getToolPreview } from '@/components/fresh-agent/shared/tool-preview' @@ -518,12 +518,6 @@ export function FreshAgentTranscript({ isStreaming = false, onForkFromTurn, onRewindToTurn, - isInitialLoading = false, - hasOlderHistory = false, - isLoadingOlder = false, - historyError, - historyErrorActionLabel = 'Retry', - onLoadOlder, }: { turns: FreshAgentTurn[] canFork?: boolean @@ -535,20 +529,8 @@ export function FreshAgentTranscript({ isStreaming?: boolean onForkFromTurn?: (turnId: string) => void onRewindToTurn?: (turn: FreshAgentTurn) => void - isInitialLoading?: boolean - hasOlderHistory?: boolean - isLoadingOlder?: boolean - historyError?: string - historyErrorActionLabel?: string - onLoadOlder?: () => void | Promise }) { const scrollerRef = useRef(null) - const layoutRef = useRef<{ - firstKey: string | null - lastKey: string | null - count: number - scrollHeight: number - } | null>(null) const [atBottom, setAtBottom] = useState(true) const [newMessages, setNewMessages] = useState(0) const [contextMenu, setContextMenu] = useState(null) @@ -561,8 +543,6 @@ export function FreshAgentTranscript({ const displayTurns = useMemo(() => ( filterTurnsForDisplay(turns, displayOptions) ), [displayOptions, turns]) - const displayTurnsRef = useRef(displayTurns) - displayTurnsRef.current = displayTurns const liveActivityBlockId = useMemo( () => selectLiveActivityBlockId(displayTurns, isStreaming, displayOptions), [displayOptions, displayTurns, isStreaming], @@ -604,39 +584,15 @@ export function FreshAgentTranscript({ onOpenActions: coarsePointer ? handleOpenActions : undefined, }), [canFork, coarsePointer, handleOpenActions, handleTurnContextMenu, onForkFromTurn, onRewindToTurn]) - const loadOlder = useCallback(() => { - if (!hasOlderHistory || isLoadingOlder) return - void onLoadOlder?.() - }, [hasOlderHistory, isLoadingOlder, onLoadOlder]) - - useLayoutEffect(() => { + useEffect(() => { const node = scrollerRef.current if (!node) return - const currentDisplayTurns = displayTurnsRef.current - const firstKey = currentDisplayTurns[0] ? getFreshAgentDisplayTurnKey(currentDisplayTurns[0]) : null - const lastKey = currentDisplayTurns.at(-1) ? getFreshAgentDisplayTurnKey(currentDisplayTurns.at(-1)!) : null - const previous = layoutRef.current - const prependedOlderHistory = Boolean( - previous - && firstKey !== previous.firstKey - && lastKey === previous.lastKey - && currentDisplayTurns.length > previous.count, - ) - if (atBottom) { node.scrollTop = node.scrollHeight setNewMessages(0) - } else if (prependedOlderHistory && previous) { - node.scrollTop += node.scrollHeight - previous.scrollHeight } else { setNewMessages((count) => count + 1) } - layoutRef.current = { - firstKey, - lastKey, - count: currentDisplayTurns.length, - scrollHeight: node.scrollHeight, - } }, [atBottom, transcriptSignature]) return ( @@ -648,47 +604,8 @@ export function FreshAgentTranscript({ onScroll={(event) => { const node = event.currentTarget setAtBottom(node.scrollHeight - node.scrollTop - node.clientHeight < 24) - if (node.scrollTop < 48) loadOlder() }} > - {hasOlderHistory || historyError ? ( -
- {historyError ? ( -
- {historyError} - {hasOlderHistory ? ( - - ) : null} -
- ) : ( - - )} -
- ) : null} - {isInitialLoading && displayTurns.length === 0 ? ( -
-
- ) : null} {displayTurns.map((turn, index) => ( , - fallback: { - sessionType: FreshAgentSnapshot['sessionType'] - provider: FreshAgentSnapshot['provider'] - threadId: string - status: string - }, -): FreshAgentSnapshot { - return { - sessionType: raw.sessionType ?? fallback.sessionType, - provider: raw.provider ?? fallback.provider, - threadId: raw.threadId ?? fallback.threadId, - ...(raw.sessionId ? { sessionId: raw.sessionId } : {}), - revision: raw.revision ?? 0, - latestTurnId: raw.latestTurnId, - status: raw.status ?? fallback.status, - summary: raw.summary, - capabilities: raw.capabilities ?? { - send: false, - interrupt: false, - approvals: false, - questions: false, - fork: false, - }, - settings: raw.settings, - tokenUsage: raw.tokenUsage ?? { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, - pendingApprovals: raw.pendingApprovals ?? [], - pendingQuestions: raw.pendingQuestions ?? [], - worktrees: raw.worktrees ?? [], - diffs: raw.diffs ?? [], - childThreads: raw.childThreads ?? [], - turns: raw.turns ?? [], - extensions: raw.extensions ?? {}, - } -} - function getEffectiveFreshAgentEffort( content: FreshAgentPaneContent, providerDefaults?: { modelSelection?: { modelId: string } }, @@ -237,34 +190,6 @@ function isFreshOpencodePlaceholderId(pane: FreshAgentPaneContent, sessionId: st && sessionId.startsWith('freshopencode-') } -function getInvalidFreshOpencodeRestoreTarget(pane: FreshAgentPaneContent): string | undefined { - if (pane.provider !== 'opencode' || pane.sessionType !== 'freshopencode') return undefined - if (pane.sessionId) return undefined - const sessionRefId = pane.sessionRef?.provider === 'opencode' ? pane.sessionRef.sessionId : undefined - if (isFreshOpencodePlaceholderId(pane, sessionRefId)) return sessionRefId - if (isFreshOpencodePlaceholderId(pane, pane.resumeSessionId)) return pane.resumeSessionId - return undefined -} - -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 - ? tab.title.trim() - : undefined - const createdAt = typeof tab.createdAt === 'number' && Number.isFinite(tab.createdAt) - ? tab.createdAt - : undefined - const updatedAt = typeof tab.updatedAt === 'number' && Number.isFinite(tab.updatedAt) - ? tab.updatedAt - : undefined - if (!title && createdAt === undefined && updatedAt === undefined) return undefined - return { - ...(title ? { title } : {}), - ...(createdAt !== undefined ? { createdAt } : {}), - ...(updatedAt !== undefined ? { updatedAt } : {}), - } -} - function getFreshAgentSnapshotThreadId( pane: FreshAgentPaneContent, claudeSession: Parameters[0], @@ -323,6 +248,25 @@ function persistDurableFreshAgentFlavor(message: { }) } +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 + ? tab.title.trim() + : undefined + const createdAt = typeof tab.createdAt === 'number' && Number.isFinite(tab.createdAt) + ? tab.createdAt + : undefined + const updatedAt = typeof tab.updatedAt === 'number' && Number.isFinite(tab.updatedAt) + ? tab.updatedAt + : undefined + if (!title && createdAt === undefined && updatedAt === undefined) return undefined + return { + ...(title ? { title } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + } +} + function getQuestionAgentLabel(paneContent: FreshAgentPaneContent, descriptorLabel?: string): string { if (paneContent.sessionType === 'kilroy') return 'Kilroy' switch (paneContent.provider) { @@ -355,12 +299,9 @@ function isLostFreshOpencodeThreadError(error: unknown): boolean { return status === 404 && code === 'FRESH_AGENT_LOST_SESSION' } -function getRestoreErrorMessage(reason: RestoreErrorReason, provider?: string): string { +function getRestoreErrorMessage(reason: RestoreErrorReason): string { switch (reason) { case 'invalid_legacy_restore_target': - if (provider === 'opencode') { - return 'This Freshopencode session cannot be resumed because only a temporary session id was saved. Start a new session.' - } return 'This session cannot be resumed because Freshell only has a legacy name, not a canonical Claude session id.' case 'dead_live_handle': return 'This session cannot be resumed because the live session handle is gone and no durable session id was saved.' @@ -550,22 +491,6 @@ export function FreshAgentView({ const snapshotThreadId = getFreshAgentSnapshotThreadId(paneContent, claudeSession) const snapshotThreadIdRef = useRef(snapshotThreadId) snapshotThreadIdRef.current = snapshotThreadId - const agentHistorySession = useAppSelector((state) => { - if (!snapshotThreadId) return undefined - return state.freshAgent.sessions[makeFreshAgentSessionKey({ - sessionId: snapshotThreadId, - sessionType: paneContent.sessionType, - provider: paneContent.provider, - })] - }) - const agentHistorySessionRef = useRef(agentHistorySession) - agentHistorySessionRef.current = agentHistorySession - const snapshotTurns = snapshot?.turns - const transcriptTurns = useMemo(() => ( - agentHistorySession - ? selectFreshAgentTranscriptTurns(agentHistorySession) - : (snapshotTurns ?? []) - ), [agentHistorySession, snapshotTurns]) const hasRestoreFailure = Boolean( paneContent.provider === 'claude' && paneContent.sessionId @@ -581,9 +506,7 @@ export function FreshAgentView({ && claudeSession?.historyLoaded !== true && !hasRestoreFailure, ) - const hasUserTurns = useMemo(() => ( - transcriptTurns.some((turn) => typeof turn.role === 'string' && turn.role.trim().toLowerCase() === 'user') - ), [transcriptTurns]) + const hasUserTurns = useMemo(() => freshAgentSnapshotHasUserTurn(snapshot), [snapshot]) const autoTitleDurableIdentity = useMemo(() => { const paneSessionRefId = paneContent.sessionRef?.provider === paneContent.provider ? paneContent.sessionRef.sessionId @@ -622,11 +545,7 @@ export function FreshAgentView({ ]) const [snapshotAutoTitleIdentity, setSnapshotAutoTitleIdentity] = useState(null) const hasCurrentSnapshot = snapshot !== null && snapshotAutoTitleIdentity === autoTitleIdentity - const transcriptHistoryLoaded = agentHistorySession?.historyLoaded === true - const snapshotIncludesTranscriptTurns = Boolean(snapshot?.turns && snapshot.turns.length > 0) - const snapshotConfirmsNoUserTurns = hasCurrentSnapshot - && !hasUserTurns - && (snapshotIncludesTranscriptTurns || transcriptHistoryLoaded) + const snapshotConfirmsNoUserTurns = hasCurrentSnapshot && !hasUserTurns const snapshotConfirmsUserTurns = hasCurrentSnapshot && hasUserTurns const currentAutoTitleIdentityRef = useRef(autoTitleIdentity) currentAutoTitleIdentityRef.current = autoTitleIdentity @@ -751,25 +670,6 @@ export function FreshAgentView({ snapshotConfirmsUserTurns, ]) - useEffect(() => { - if (hidden || paneContent.restoreError) return - const invalidRestoreTarget = getInvalidFreshOpencodeRestoreTarget(paneContent) - if (!invalidRestoreTarget) return - dispatch(updatePaneContent({ - tabId, - paneId, - content: { - ...paneContent, - sessionId: undefined, - resumeSessionId: undefined, - sessionRef: undefined, - restoreError: buildRestoreError('invalid_legacy_restore_target'), - createError: undefined, - status: 'idle', - }, - })) - }, [dispatch, hidden, paneContent, paneId, tabId]) - const buildCreateMessage = useCallback((content: FreshAgentPaneContent) => { const legacyRestoreContext = content.provider === 'opencode' ? buildLegacyRestoreContext(tabRestoreSource) @@ -1153,12 +1053,7 @@ export function FreshAgentView({ .then((next) => { if (isStaleSnapshotRequest()) return const snapshotIdentity = currentAutoTitleIdentityRef.current - const resolved = normalizeSnapshotForDisplay(next as Partial, { - sessionType: requestSessionType, - provider, - threadId: sessionId, - status: paneContentRef.current.status, - }) + const resolved = next as FreshAgentSnapshot const resolvedHasUserTurns = freshAgentSnapshotHasUserTurn(resolved) if (!resolvedHasUserTurns && !autoTitleSentRef.current) { autoTitleFreshBoundaryRef.current = true @@ -1169,7 +1064,6 @@ export function FreshAgentView({ } const displaySnapshot = mergeSnapshotForDisplay(snapshotRef.current, resolved) commitSnapshot(displaySnapshot) - dispatch(freshAgentSnapshotReceived({ snapshot: displaySnapshot, hydrateHistory: false })) setSnapshotAutoTitleIdentity(snapshotIdentity) const echo = localEchoRef.current const landedEcho = echo @@ -1185,8 +1079,7 @@ export function FreshAgentView({ : undefined const nextSessionId = snapshotSessionRef?.sessionId ?? fresh.sessionId const nextSessionRef = snapshotSessionRef ?? fresh.sessionRef - const durableRequestSessionId = isDurableProviderSessionId(provider, sessionId) ? sessionId : undefined - const nextResumeSessionId = snapshotSessionRef?.sessionId ?? fresh.resumeSessionId ?? durableRequestSessionId + const nextResumeSessionId = snapshotSessionRef?.sessionId ?? fresh.resumeSessionId ?? sessionId if (snapshotSessionRef) { migratePendingAutoTitle(fresh.sessionId, snapshotSessionRef.sessionId, provider) } @@ -1285,72 +1178,6 @@ export function FreshAgentView({ tabId, ]) - useEffect(() => { - if (!snapshotThreadId || hidden) return - if (paneContent.provider === 'claude' && claudeSession?.lost) return - const requestSessionType = paneContent.sessionType - const requestProvider = paneContent.provider - const requestSessionId = snapshotThreadId - const requestCreateRequestId = paneContent.createRequestId - const requestRefreshNonce = snapshotRefreshNonce - const requestKey = `${requestCreateRequestId}:${requestSessionType}:${requestProvider}:${requestSessionId}:${requestRefreshNonce}` - const requestCwd = paneContent.initialCwd - const shouldAutoBackfill = agentHistorySessionRef.current?.historyLoaded !== true - const isStaleHistoryRequest = () => ( - paneContentRef.current.createRequestId !== requestCreateRequestId - || paneContentRef.current.provider !== requestProvider - || paneContentRef.current.sessionType !== requestSessionType - || snapshotThreadIdRef.current !== requestSessionId - ) - - void dispatch(loadFreshAgentThreadTurns({ - sessionType: requestSessionType, - provider: requestProvider, - sessionId: requestSessionId, - cwd: requestCwd, - limit: INITIAL_HISTORY_TURN_LIMIT, - includeBodies: true, - priority: 'visible', - requestKey, - })).unwrap() - .then((page) => { - if (isStaleHistoryRequest()) return - if (!shouldAutoBackfill || !page.nextCursor) return - return dispatch(backfillFreshAgentOlderHistory({ - sessionType: requestSessionType, - provider: requestProvider, - sessionId: requestSessionId, - cwd: requestCwd, - revision: page.revision, - cursor: page.nextCursor, - requestKey, - limit: INITIAL_HISTORY_TURN_LIMIT, - })) - }) - .catch((error: unknown) => { - if (isStaleHistoryRequest()) return - setLoadError(error instanceof Error ? error.message : 'Failed to load session history') - }) - }, [ - claudeSession?.lost, - dispatch, - hidden, - paneContent.createRequestId, - paneContent.initialCwd, - paneContent.provider, - paneContent.sessionType, - snapshotRefreshNonce, - snapshotThreadId, - ]) - - useEffect(() => { - const echo = localEchoRef.current - if (!echo) return - if (localEchoLanded(transcriptTurns, echo, pendingSendMetadataRef.current.get(echo.requestId))) { - setLocalEcho(null) - } - }, [setLocalEcho, transcriptTurns]) - const claudeSessionStatus = claudeSession?.status useEffect(() => { if (paneContent.provider !== 'claude') return @@ -1620,36 +1447,8 @@ export function FreshAgentView({ }) }, [snapshot?.turns]) - const loadOlderHistory = useCallback(() => { - if (!snapshotThreadId) return - if (shouldRefreshHistoryForOlderError(agentHistorySession?.historyOlderError)) { - setSnapshotRefreshNonce((value) => value + 1) - return - } - if (!agentHistorySession?.nextHistoryCursor || typeof agentHistorySession.historyRevision !== 'number') return - const requestKey = agentHistorySession.historyInitialRequestKey - ?? `${paneContentRef.current.createRequestId}:${paneContentRef.current.sessionType}:${paneContentRef.current.provider}:${snapshotThreadId}` - void dispatch(backfillFreshAgentOlderHistory({ - sessionType: paneContentRef.current.sessionType, - provider: paneContentRef.current.provider, - sessionId: snapshotThreadId, - cwd: paneContentRef.current.initialCwd, - revision: agentHistorySession.historyRevision, - cursor: agentHistorySession.nextHistoryCursor, - requestKey, - limit: INITIAL_HISTORY_TURN_LIMIT, - })) - }, [ - agentHistorySession?.historyOlderError, - agentHistorySession?.historyInitialRequestKey, - agentHistorySession?.historyRevision, - agentHistorySession?.nextHistoryCursor, - dispatch, - snapshotThreadId, - ]) - const content = useMemo(() => { - const turns = transcriptTurns + const turns = snapshot?.turns ?? [] const pendingApprovals = snapshot?.pendingApprovals ?? [] const pendingQuestions = snapshot?.pendingQuestions ?? [] const worktrees = snapshot?.worktrees ?? [] @@ -1687,9 +1486,8 @@ export function FreshAgentView({ : null const visiblePaneRestoreFailure = visibleRestoreFailure ? null - : (paneContent.restoreError ? getRestoreErrorMessage(paneContent.restoreError.reason, paneContent.provider) : null) - const visibleHistoryError = agentHistorySession?.historyError ?? null - const visibleLoadError = visibleRestoreFailure || visiblePaneRestoreFailure || isRestoring ? null : (loadError ?? visibleHistoryError) + : (paneContent.restoreError ? getRestoreErrorMessage(paneContent.restoreError.reason) : null) + const visibleLoadError = visibleRestoreFailure || visiblePaneRestoreFailure || isRestoring ? null : loadError const WatermarkIcon = descriptor?.icon const handlePanePointerUp = (event: ReactPointerEvent) => { if (isEditableTarget(event.target)) return @@ -1875,12 +1673,6 @@ export function FreshAgentView({ isStreaming={isBusy} onForkFromTurn={(turnId) => sendFork(turnId)} onRewindToTurn={paneContent.initialCwd ? rewindToTurn : undefined} - isInitialLoading={agentHistorySession?.historyInitialLoading === true && !localEcho} - hasOlderHistory={Boolean(agentHistorySession?.nextHistoryCursor)} - isLoadingOlder={agentHistorySession?.historyOlderLoading === true} - historyError={agentHistorySession?.historyOlderError} - historyErrorActionLabel={shouldRefreshHistoryForOlderError(agentHistorySession?.historyOlderError) ? 'Refresh' : 'Retry'} - onLoadOlder={loadOlderHistory} /> { diff --git a/src/lib/api.ts b/src/lib/api.ts index eeb84e087..ef2558388 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -338,7 +338,7 @@ export async function getFreshAgentTurnPage( query: { cursor?: string priority?: string - revision?: number + revision: number cwd?: string limit?: number includeBodies?: boolean diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index b0d312b1c..02fef6bda 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -1,9 +1,5 @@ -import type { AppDispatch, RootState } from '@/store/store' -import { - makeFreshAgentSessionKey, - type FreshAgentRuntimeProvider, - type FreshAgentSessionType, -} from '@shared/fresh-agent' +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 { flushPersistedLayoutNow } from '@/store/persistControl' @@ -144,24 +140,16 @@ export function handleFreshAgentMessage(dispatch: AppDispatch, msg: Record RootState) => { - const sessionKey = makeFreshAgentSessionKey({ - sessionId: materialized.sessionId, - sessionType: materialized.sessionType, + dispatch(materializeFreshAgentPaneSession({ + previousSessionId: materialized.previousSessionId, + sessionId: materialized.sessionId, + sessionType: materialized.sessionType, + provider: materialized.provider, + sessionRef: materialized.sessionRef ?? { provider: materialized.provider, - }) - innerDispatch(materializeFreshAgentPaneSession({ - previousSessionId: materialized.previousSessionId, sessionId: materialized.sessionId, - sessionType: materialized.sessionType, - provider: materialized.provider, - sessionRef: materialized.sessionRef ?? { - provider: materialized.provider, - sessionId: materialized.sessionId, - }, - status: getState().freshAgent.sessions[sessionKey]?.status, - })) - }) + }, + })) dispatch(flushPersistedLayoutNow()) return true } diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index d43839bfb..5df187b52 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -4,8 +4,7 @@ import { type FreshAgentRuntimeProvider, type FreshAgentSessionType, } from '@shared/fresh-agent' -import { getFreshAgentTurnIdentityKeys, isTemporaryFreshAgentTurnId } from '@shared/fresh-agent-turns' -import type { FreshAgentSnapshot, FreshAgentTurn } from '@shared/fresh-agent-contract' +import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' import type { FreshAgentContentBlock, FreshAgentPermissionRequest, @@ -112,106 +111,12 @@ function resetHydratedHistoryState(session: FreshAgentSessionState): void { session.nextHistoryCursor = undefined session.historyLoading = false session.historyError = undefined - session.historyInitialLoading = false - session.historyOlderLoading = false - session.historyOlderError = undefined - session.historyBackfillComplete = false - session.historyBackfillPaused = false - session.historyInitialRequestKey = undefined - session.historyOlderRequestKey = undefined session.historyLoaded = false session.restoreFailureMessage = undefined session.streamingText = '' session.streamingActive = false } -function mergeUniqueTurnsByIdentity( - first: FreshAgentTurn[], - second: FreshAgentTurn[], -): FreshAgentTurn[] { - const seen = new Set() - const merged: FreshAgentTurn[] = [] - for (const turn of [...first, ...second]) { - const keys = getFreshAgentTurnIdentityKeys(turn) - if (keys.length > 0 && keys.some((key) => seen.has(key))) continue - for (const key of keys) seen.add(key) - merged.push(turn) - } - return merged -} - -function mergeTurnsReplacingByIdentity( - existing: FreshAgentTurn[], - incoming: FreshAgentTurn[], -): FreshAgentTurn[] { - const incomingByKey = new Map() - const consumed = new Set() - for (const turn of incoming) { - for (const key of getFreshAgentTurnIdentityKeys(turn)) { - incomingByKey.set(key, turn) - } - } - - const merged = existing.map((turn) => { - const replacement = getFreshAgentTurnIdentityKeys(turn) - .map((key) => incomingByKey.get(key)) - .find((candidate): candidate is FreshAgentTurn => Boolean(candidate)) - if (replacement) { - consumed.add(replacement) - return replacement - } - return turn - }) - - for (const turn of incoming) { - if (!consumed.has(turn)) merged.push(turn) - } - return mergeUniqueTurnsByIdentity([], merged) -} - -function isSnapshotStatusInFlight(status: string | undefined): boolean { - return status === 'running' || status === 'compacting' -} - -function appendLiveTurn(session: FreshAgentSessionState, turn: FreshAgentTurn): void { - session.turns = mergeUniqueTurnsByIdentity(session.turns, [turn]) - session.historyBodies[turn.turnId] = turn -} - -export function selectFreshAgentTranscriptTurns(session: FreshAgentSessionState): FreshAgentTurn[] { - if (session.historyItems.length === 0) { - return session.turns - } - - const loadedKeys = new Set(session.historyItems.flatMap(getFreshAgentTurnIdentityKeys)) - const loadedOrdinals = session.historyItems - .map((turn) => turn.ordinal) - .filter((ordinal): ordinal is number => typeof ordinal === 'number') - const maxLoadedOrdinal = loadedOrdinals.length > 0 ? Math.max(...loadedOrdinals) : undefined - const loadedTimestamps = session.historyItems - .map((turn) => (turn.timestamp ? Date.parse(turn.timestamp) : Number.NaN)) - .filter(Number.isFinite) - const maxLoadedTimestamp = loadedTimestamps.length > 0 ? Math.max(...loadedTimestamps) : undefined - - const liveOrNewTurns = session.turns.filter((turn) => { - if (getFreshAgentTurnIdentityKeys(turn).some((key) => loadedKeys.has(key))) { - return false - } - if (maxLoadedOrdinal !== undefined && typeof turn.ordinal === 'number') { - return turn.ordinal > maxLoadedOrdinal - } - if (maxLoadedTimestamp !== undefined && turn.timestamp) { - const timestamp = Date.parse(turn.timestamp) - if (Number.isFinite(timestamp)) return timestamp > maxLoadedTimestamp - } - return turn.source === 'live' - || isTemporaryFreshAgentTurnId(turn.turnId) - || isTemporaryFreshAgentTurnId(turn.id) - }) - - return mergeUniqueTurnsByIdentity(session.historyItems, liveOrNewTurns) -} - function requestRestoreHydrationRestart(session: FreshAgentSessionState): void { session.restoreHydrationRequestId = (session.restoreHydrationRequestId ?? 0) + 1 } @@ -454,17 +359,8 @@ const freshAgentSlice = createSlice({ } }, - freshAgentSnapshotReceived(state, action: PayloadAction<{ snapshot: FreshAgentSnapshot; hydrateHistory?: boolean }>) { + freshAgentSnapshotReceived(state, action: PayloadAction<{ snapshot: FreshAgentSnapshot }>) { const snapshot = action.payload.snapshot - const hydrateHistory = action.payload.hydrateHistory !== false - const snapshotTurns = snapshot.turns ?? [] - const pendingApprovals = snapshot.pendingApprovals ?? [] - const pendingQuestions = snapshot.pendingQuestions ?? [] - const tokenUsage = snapshot.tokenUsage ?? { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - } const session = ensureSession(state, { sessionId: snapshot.threadId, sessionType: snapshot.sessionType, @@ -474,28 +370,19 @@ const freshAgentSlice = createSlice({ session.status = snapshot.status as FreshAgentSessionStatus session.latestTurnId = snapshot.latestTurnId session.historyRevision = snapshot.revision - if (hydrateHistory) { - session.turns = snapshotTurns - session.historyItems = snapshotTurns - session.historyBodies = Object.fromEntries(snapshotTurns.map((turn) => [turn.turnId, turn])) - } else if (snapshotTurns.length > 0) { - session.turns = isSnapshotStatusInFlight(snapshot.status) - ? mergeTurnsReplacingByIdentity(session.turns, snapshotTurns) - : snapshotTurns - for (const turn of snapshotTurns) { - session.historyBodies[turn.turnId] = turn - } - } + session.turns = snapshot.turns + session.historyItems = snapshot.turns + session.historyBodies = Object.fromEntries(snapshot.turns.map((turn) => [turn.turnId, turn])) session.pendingPermissions = Object.fromEntries( - pendingApprovals.map((approval) => [String(approval.requestId), approval]), + snapshot.pendingApprovals.map((approval) => [String(approval.requestId), approval]), ) session.pendingQuestions = Object.fromEntries( - pendingQuestions.map((question) => [String(question.requestId), question]), + snapshot.pendingQuestions.map((question) => [String(question.requestId), question]), ) - session.totalInputTokens = tokenUsage.inputTokens - session.totalOutputTokens = tokenUsage.outputTokens - session.totalCostUsd = tokenUsage.costUsd ?? 0 - session.historyLoaded = hydrateHistory ? true : session.historyLoaded + session.totalInputTokens = snapshot.tokenUsage.inputTokens + session.totalOutputTokens = snapshot.tokenUsage.outputTokens + session.totalCostUsd = snapshot.tokenUsage.costUsd ?? 0 + session.historyLoaded = true session.awaitingDurableHistory = false }, @@ -591,103 +478,39 @@ const freshAgentSlice = createSlice({ session.restoreRetryCount = (session.restoreRetryCount ?? 0) + 1 }, - historyLoadStarted(state, action: PayloadAction) { - const session = resolveOrEnsureSession(state, action.payload) - if (!session) return - session.historyLoading = true - if (action.payload.cursor) { - session.historyOlderLoading = true - session.historyOlderError = undefined - session.historyOlderRequestKey = action.payload.requestKey - } else { - session.historyInitialLoading = true - session.historyError = undefined - session.historyOlderError = undefined - session.historyInitialRequestKey = action.payload.requestKey - } + historyLoadStarted(state, action: PayloadAction) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + state.sessions[key].historyLoading = true + state.sessions[key].historyError = undefined }, historyPageReceived(state, action: PayloadAction nextCursor?: string | null revision?: number - cursor?: string - requestKey?: string }>) { - const session = resolveOrEnsureSession(state, action.payload) - if (!session) return - const expectedRequestKey = action.payload.cursor - ? session.historyOlderRequestKey - : session.historyInitialRequestKey - if (action.payload.requestKey && expectedRequestKey && action.payload.requestKey !== expectedRequestKey) return - if (session.restoreFailureMessage) return - const incoming = action.payload.turns - const loadingOlderPage = Boolean(action.payload.cursor) - const hadHistoryItems = session.historyItems.length > 0 - const previousRevision = session.historyRevision - const nextRevision = action.payload.revision ?? previousRevision - const revisionChanged = previousRevision !== undefined - && nextRevision !== undefined - && previousRevision !== nextRevision + const key = resolveSessionKey(state, action.payload) + if (!key) return + const session = state.sessions[key] session.historyLoading = false - session.historyInitialLoading = false - session.historyOlderLoading = false session.historyLoaded = true - session.historyItems = loadingOlderPage - ? mergeUniqueTurnsByIdentity(incoming, session.historyItems) - : hadHistoryItems - ? mergeTurnsReplacingByIdentity(session.historyItems, incoming) - : incoming - for (const turn of incoming) { - session.historyBodies[turn.turnId] = turn - } - for (const [turnId, body] of Object.entries(action.payload.bodies ?? {})) { - session.historyBodies[turnId] = body - } - if (loadingOlderPage || !hadHistoryItems || revisionChanged) { - session.nextHistoryCursor = action.payload.nextCursor - session.historyBackfillComplete = action.payload.nextCursor == null - } else if (action.payload.nextCursor == null) { - session.nextHistoryCursor = null - session.historyBackfillComplete = true - } - session.historyRevision = nextRevision - session.historyBackfillPaused = false + session.historyItems = action.payload.turns + session.nextHistoryCursor = action.payload.nextCursor + session.historyRevision = action.payload.revision ?? session.historyRevision }, - historyLoadFailed(state, action: PayloadAction) { - const session = resolveOrEnsureSession(state, action.payload) - if (!session) return - const expectedRequestKey = action.payload.cursor - ? session.historyOlderRequestKey - : session.historyInitialRequestKey - if (action.payload.requestKey && expectedRequestKey && action.payload.requestKey !== expectedRequestKey) return + historyLoadFailed(state, action: PayloadAction) { + const key = resolveSessionKey(state, action.payload) + if (!key) return + const session = state.sessions[key] session.historyLoading = false - if (action.payload.cursor) { - session.historyOlderLoading = false - session.historyOlderError = action.payload.message - session.historyBackfillPaused = true - } else { - session.historyInitialLoading = false - session.historyError = action.payload.message - } + session.historyError = action.payload.message }, - turnBodyReceived(state, action: PayloadAction) { + turnBodyReceived(state, action: PayloadAction) { const key = resolveSessionKey(state, action.payload) if (!key) return - if (action.payload.revision !== undefined && state.sessions[key].historyRevision !== undefined && action.payload.revision !== state.sessions[key].historyRevision) return state.sessions[key].historyBodies[action.payload.turn.turnId] = action.payload.turn }, @@ -708,13 +531,14 @@ const freshAgentSlice = createSlice({ const session = resolveOrEnsureSession(state, action.payload, 'running') if (!session) return const turnId = `live-user-${session.turns.length + 1}` - appendLiveTurn(session, { + session.turns.push({ id: turnId, turnId, role: 'user', summary: action.payload.text, items: [{ id: `${turnId}-text`, kind: 'text', text: action.payload.text }], }) + session.historyItems = session.turns }, addAssistantMessage(state, action: PayloadAction[] @@ -726,7 +550,7 @@ const freshAgentSlice = createSlice({ .map((block, index) => normalizeLegacyContentBlock(block, index)) .filter((item): item is FreshAgentContentBlock => item !== undefined) const turnId = `live-assistant-${session.turns.length + 1}` - appendLiveTurn(session, { + session.turns.push({ id: turnId, turnId, role: 'assistant', @@ -734,6 +558,7 @@ const freshAgentSlice = createSlice({ summary: summarizeFreshAgentItems(items), items, }) + session.historyItems = session.turns session.streamingText = '' session.streamingActive = false }, diff --git a/src/store/freshAgentThunks.ts b/src/store/freshAgentThunks.ts index 3230dfabe..54dbde553 100644 --- a/src/store/freshAgentThunks.ts +++ b/src/store/freshAgentThunks.ts @@ -15,18 +15,8 @@ type FreshAgentThreadThunkLocator = { cwd?: string } -const BACKGROUND_HISTORY_MAX_PAGES_PER_BATCH = 8 - const inFlightControllers = new Set() -function errorMessage(error: unknown): string | undefined { - if (error instanceof Error) return error.message - if (error && typeof error === 'object' && typeof (error as { message?: unknown }).message === 'string') { - return (error as { message: string }).message - } - return undefined -} - export function _resetFreshAgentThunkControllers(): void { for (const controller of inFlightControllers) { controller.abort() @@ -38,13 +28,10 @@ export const loadFreshAgentThreadTurns = createAsyncThunk( 'freshAgent/loadThreadTurns', async ( input: FreshAgentThreadThunkLocator & { - revision?: number + revision: number cursor?: string - priority?: 'visible' | 'background' - requestKey?: string limit?: number includeBodies?: boolean - suppressFailureDispatch?: boolean }, { dispatch }, ) => { @@ -59,7 +46,6 @@ export const loadFreshAgentThreadTurns = createAsyncThunk( { revision: input.revision, cursor: input.cursor, - priority: input.priority, limit: input.limit, includeBodies: input.includeBodies, cwd: input.cwd, @@ -69,18 +55,15 @@ export const loadFreshAgentThreadTurns = createAsyncThunk( dispatch(historyPageReceived({ ...input, turns: page.turns, - bodies: page.bodies ?? {}, nextCursor: page.nextCursor, revision: page.revision, })) return page } catch (error) { - if (!input.suppressFailureDispatch) { - dispatch(historyLoadFailed({ - ...input, - message: errorMessage(error) ?? 'Failed to load fresh-agent history', - })) - } + dispatch(historyLoadFailed({ + ...input, + message: error instanceof Error ? error.message : 'Failed to load fresh-agent history', + })) throw error } finally { inFlightControllers.delete(controller) @@ -88,45 +71,6 @@ export const loadFreshAgentThreadTurns = createAsyncThunk( }, ) -export const backfillFreshAgentOlderHistory = createAsyncThunk( - 'freshAgent/backfillOlderHistory', - async ( - input: FreshAgentThreadThunkLocator & { - revision: number - cursor: string - requestKey: string - limit?: number - }, - { dispatch }, - ) => { - let cursor: string | null | undefined = input.cursor - let revision = input.revision - for (let page = 0; cursor && page < BACKGROUND_HISTORY_MAX_PAGES_PER_BATCH; page += 1) { - try { - const result = await dispatch(loadFreshAgentThreadTurns({ - ...input, - revision, - cursor, - priority: 'background', - limit: input.limit ?? 30, - includeBodies: true, - suppressFailureDispatch: true, - })).unwrap() - cursor = result.nextCursor - revision = result.revision - } catch (error) { - const rawMessage = errorMessage(error) - const message = rawMessage && /(cursor|stale|revision)/i.test(rawMessage) - ? 'Older history cursor expired; refresh history to continue.' - : (rawMessage ?? 'Failed to load older fresh-agent history') - dispatch(historyLoadFailed({ ...input, cursor: cursor ?? input.cursor, message })) - throw error - } - } - return { nextCursor: cursor ?? null, revision } - }, -) - export const loadFreshAgentTurnBody = createAsyncThunk( 'freshAgent/loadTurnBody', async ( @@ -150,7 +94,7 @@ export const loadFreshAgentTurnBody = createAsyncThunk( signal: controller.signal, }, ) - dispatch(turnBodyReceived({ ...input, turn, revision: input.revision })) + dispatch(turnBodyReceived({ ...input, turn })) return turn } finally { inFlightControllers.delete(controller) diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index a5418787c..782f7829a 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -65,13 +65,6 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { nextHistoryCursor?: string | null historyLoading?: boolean historyError?: string - historyInitialLoading?: boolean - historyOlderLoading?: boolean - historyOlderError?: string - historyBackfillComplete?: boolean - historyBackfillPaused?: boolean - historyInitialRequestKey?: string - historyOlderRequestKey?: string streamingText: string streamingActive: boolean pendingPermissions: Record diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 515934983..aa6583b66 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -41,7 +41,6 @@ type FreshAgentSessionMaterializedPayload = { sessionType: FreshAgentPaneContent['sessionType'] provider: FreshAgentPaneContent['provider'] sessionRef?: SessionLocator - status?: FreshAgentPaneContent['status'] } function buildPreservedSessionRef( @@ -110,7 +109,6 @@ function normalizePaneContent( const style = normalizeFreshAgentStyleOverride((input as { style?: unknown }).style) const pendingLocalEcho = normalizeFreshAgentPendingLocalEcho(rawFreshAgent.pendingLocalEcho) const status = input.status || (pendingLocalEcho ? 'running' : 'creating') - const normalizedStatus = existingRestoreError ? 'idle' : status if (existingRestoreError) { return { kind: 'fresh-agent', @@ -118,7 +116,7 @@ function normalizePaneContent( provider: input.provider, sessionId: input.sessionId, createRequestId: input.createRequestId || nanoid(), - status: normalizedStatus, + status, ...(existingRestoreError.reason === 'invalid_legacy_restore_target' ? {} : { resumeSessionId: input.resumeSessionId }), @@ -578,7 +576,6 @@ function buildMaterializedFreshAgentContent( sessionId: materialized.sessionId, resumeSessionId: materialized.sessionId, sessionRef, - status: materialized.status ?? content.status, restoreError: undefined, }, content) as FreshAgentPaneContent } diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index 57c63a115..66df43146 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -17,8 +17,7 @@ import { import { LAYOUT_STORAGE_KEY, PANES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY, TURN_COMPLETION_STORAGE_KEY } from './storage-keys' import { createLogger } from '@/lib/client-logger' import { flushPersistedLayoutNow } from './persistControl' -import { buildRestoreError, sanitizeSessionRef } from '@shared/session-contract' -import { isDurableProviderSessionId } from '@shared/session-flavor' +import { sanitizeSessionRef } from '@shared/session-contract' import { normalizeFreshAgentEffortOverride, normalizeFreshAgentModelSelection } from './paneTypes' import { loadPersistedTabRecency, @@ -205,12 +204,6 @@ function stripTransientSessionFields(content: any): any { if (content.kind !== 'terminal' && content.kind !== 'fresh-agent') return content const sessionRef = sanitizeSessionRef(content.sessionRef) - const invalidFreshAgentSessionRef = Boolean( - content.kind === 'fresh-agent' - && sessionRef - && !isDurableProviderSessionId(sessionRef.provider, sessionRef.sessionId), - ) - const durableSessionRef = invalidFreshAgentSessionRef ? undefined : sessionRef const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, @@ -220,17 +213,10 @@ function stripTransientSessionFields(content: any): any { return { ...rest, - ...(content.kind === 'fresh-agent' && content.restoreError ? { status: 'idle' } : {}), - ...(invalidFreshAgentSessionRef - ? { - status: 'idle', - restoreError: buildRestoreError('invalid_legacy_restore_target'), - } - : {}), - ...(content.kind === 'fresh-agent' && !durableSessionRef && typeof content.serverInstanceId === 'string' && typeof content.sessionId === 'string' + ...(content.kind === 'fresh-agent' && !sessionRef && typeof content.serverInstanceId === 'string' && typeof content.sessionId === 'string' ? { sessionId: content.sessionId } : {}), - ...(durableSessionRef ? { sessionRef: durableSessionRef } : {}), + ...(sessionRef ? { sessionRef } : {}), } } diff --git a/test/integration/server/fresh-agent-claude-history-route-parity.test.ts b/test/integration/server/fresh-agent-claude-history-route-parity.test.ts index f99cd0e1c..21bfcb429 100644 --- a/test/integration/server/fresh-agent-claude-history-route-parity.test.ts +++ b/test/integration/server/fresh-agent-claude-history-route-parity.test.ts @@ -119,7 +119,7 @@ describe('fresh-agent Claude history route parity', () => { expect(defaultPage.body.revision).toBe(7) expect(defaultPage.body.turns).toHaveLength(20) expect(defaultPage.body.turns.map((turn: { turnId: string }) => turn.turnId)).toEqual( - Array.from({ length: 20 }, (_, offset) => `turn-${15 + offset}`), + Array.from({ length: 20 }, (_, offset) => `turn-${34 - offset}`), ) expect(defaultPage.body.nextCursor).toEqual(expect.any(String)) @@ -135,7 +135,7 @@ describe('fresh-agent Claude history route parity', () => { expect(inlineBodies.status).toBe(200) expect(inlineBodies.body.revision).toBe(7) expect(inlineBodies.body.nextCursor).toEqual(expect.any(String)) - expect(inlineBodies.body.turns.map((turn: { turnId: string }) => turn.turnId)).toEqual(['turn-33', 'turn-34']) + expect(inlineBodies.body.turns.map((turn: { turnId: string }) => turn.turnId)).toEqual(['turn-34', 'turn-33']) expect(Object.keys(inlineBodies.body.bodies).sort()).toEqual(['turn-33', 'turn-34']) expect(inlineBodies.body.bodies['turn-34'].items[0]).toMatchObject({ kind: 'text', @@ -161,13 +161,7 @@ describe('fresh-agent Claude history route parity', () => { const unpinnedPage = await request(app) .get('/api/fresh-agent/threads/freshclaude/claude/thread-parity/turns') - expect(unpinnedPage.status).toBe(200) - expect(unpinnedPage.body.revision).toBe(7) - - const cursorWithoutRevision = await request(app) - .get(`/api/fresh-agent/threads/freshclaude/claude/thread-parity/turns?cursor=${encodeURIComponent(defaultPage.body.nextCursor)}`) - - expect(cursorWithoutRevision.status).toBe(400) + expect(unpinnedPage.status).toBe(400) const unpinnedTurnBody = await request(app) .get('/api/fresh-agent/threads/freshclaude/claude/thread-parity/turns/turn-34') diff --git a/test/server/agent-api-fresh-agent.test.ts b/test/server/agent-api-fresh-agent.test.ts index 09940f643..d42e1c6b5 100644 --- a/test/server/agent-api-fresh-agent.test.ts +++ b/test/server/agent-api-fresh-agent.test.ts @@ -14,7 +14,6 @@ function makeApp(overrides: { freshAgentRuntimeManager?: any } = {}) { send: vi.fn(async () => undefined), attach: vi.fn(async () => ({ sessionId: 'ses_real_1' })), getSnapshot: vi.fn(async () => ({ status: 'idle', turns: [] })), - getTurnPage: vi.fn(async () => ({ revision: 1, nextCursor: null, turns: [], bodies: {} })), } const app = express() app.use(express.json()) @@ -180,102 +179,21 @@ describe('agent-api fresh-agent: send-keys', () => { describe('agent-api fresh-agent: capture', () => { it('renders the transcript text for a fresh-agent pane', async () => { const getSnapshot = vi.fn(async () => ({ - status: 'idle', - turns: [], - })) - const getTurnPage = vi.fn(async () => ({ - revision: 10, - nextCursor: null, turns: [ { role: 'user', summary: 'Reply with: ok', items: [{ kind: 'text', text: 'Reply with: ok' }] }, { role: 'assistant', summary: 'ok', items: [{ kind: 'text', text: 'ok' }] }, ], - bodies: {}, })) const { app } = makeApp({ freshAgentRuntimeManager: { create: vi.fn(async () => ({ sessionId: 'freshopencode-abc', sessionType: 'freshopencode', runtimeProvider: 'opencode' })), - send: vi.fn(), attach: vi.fn(), getSnapshot, getTurnPage, + send: vi.fn(), attach: vi.fn(), getSnapshot, } }) const created = await request(app).post('/api/tabs').send({ agent: 'opencode' }) const res = await request(app).get(`/api/panes/${created.body.data.paneId}/capture`) expect(res.status).toBe(200) expect(res.text).toContain('user: Reply with: ok') expect(res.text).toContain('assistant: ok') - expect(getSnapshot).not.toHaveBeenCalled() - expect(getTurnPage).toHaveBeenCalledWith({ - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'freshopencode-abc', - limit: 200, - includeBodies: true, - priority: 'visible', - }) - }) - - it('walks all fresh-agent transcript pages before rendering capture text', async () => { - const getTurnPage = vi.fn() - .mockResolvedValueOnce({ - revision: 10, - nextCursor: 'older-page', - turns: [ - { role: 'user', summary: 'Middle request', items: [{ kind: 'text', text: 'Middle request' }] }, - { role: 'assistant', summary: 'Newest answer', items: [{ kind: 'text', text: 'Newest answer' }] }, - ], - bodies: {}, - }) - .mockResolvedValueOnce({ - revision: 10, - nextCursor: null, - turns: [ - { role: 'assistant', summary: 'Oldest answer', items: [{ kind: 'text', text: 'Oldest answer' }] }, - ], - bodies: {}, - }) - const { app } = makeApp({ freshAgentRuntimeManager: { - create: vi.fn(async () => ({ sessionId: 'freshopencode-abc', sessionType: 'freshopencode', runtimeProvider: 'opencode' })), - send: vi.fn(), - attach: vi.fn(), - getSnapshot: vi.fn(async () => ({ status: 'idle', turns: [] })), - getTurnPage, - } }) - const created = await request(app).post('/api/tabs').send({ agent: 'opencode' }) - const res = await request(app).get(`/api/panes/${created.body.data.paneId}/capture`) - - expect(res.status).toBe(200) - expect(res.text.indexOf('assistant: Oldest answer')).toBeLessThan(res.text.indexOf('user: Middle request')) - expect(res.text.indexOf('user: Middle request')).toBeLessThan(res.text.indexOf('assistant: Newest answer')) - expect(getTurnPage).toHaveBeenNthCalledWith(1, { - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'freshopencode-abc', - limit: 200, - includeBodies: true, - priority: 'visible', - }) - expect(getTurnPage).toHaveBeenNthCalledWith(2, { - sessionType: 'freshopencode', - provider: 'opencode', - threadId: 'freshopencode-abc', - cursor: 'older-page', - revision: 10, - limit: 200, - includeBodies: true, - priority: 'visible', - }) - }) - - it('returns a clear error when the canonical transcript pager is unavailable', async () => { - const { app } = makeApp({ freshAgentRuntimeManager: { - create: vi.fn(async () => ({ sessionId: 'freshopencode-abc', sessionType: 'freshopencode', runtimeProvider: 'opencode' })), - send: vi.fn(), - attach: vi.fn(), - getSnapshot: vi.fn(async () => ({ status: 'idle', turns: [{ role: 'assistant', items: [{ kind: 'text', text: 'old path' }] }] })), - } }) - const created = await request(app).post('/api/tabs').send({ agent: 'opencode' }) - const res = await request(app).get(`/api/panes/${created.body.data.paneId}/capture`) - - expect(res.status).toBe(503) - expect(res.body.message).toContain('fresh-agent transcript paging not available') + expect(getSnapshot).toHaveBeenCalledWith({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'freshopencode-abc' }) }) }) diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index 15d3d12c2..3c9095186 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -35,52 +35,6 @@ describe('FreshAgentTranscript', () => { expect(screen.getByText('Hello from Fresh Agent')).toBeInTheDocument() }) - it('renders load-older controls and retries older history errors', () => { - const onLoadOlder = vi.fn() - - render( - , - ) - - expect(screen.getByText('Older history cursor expired')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - }) - - it('renders initial history loading before restored turns arrive', () => { - render() - - expect(screen.getByRole('status')).toHaveTextContent('Restoring history') - }) - - it('can label expired older-history recovery as refresh', () => { - const onLoadOlder = vi.fn() - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - }) - it('uses the pane agent label for assistant turns when provided', () => { render( ({ const apiMock = vi.hoisted(() => ({ getFreshAgentThreadSnapshot: vi.fn(), getFreshAgentModelCapabilities: vi.fn(), - getFreshAgentTurnPage: vi.fn(), - getFreshAgentTurnBody: vi.fn(), post: vi.fn(), setSessionMetadata: vi.fn().mockResolvedValue(undefined), })) @@ -47,8 +45,6 @@ vi.mock('@/lib/api', async () => { api: { ...actual.api, post: apiMock.post }, getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, getFreshAgentModelCapabilities: apiMock.getFreshAgentModelCapabilities, - getFreshAgentTurnPage: apiMock.getFreshAgentTurnPage, - getFreshAgentTurnBody: apiMock.getFreshAgentTurnBody, setSessionMetadata: apiMock.setSessionMetadata, } }) @@ -190,21 +186,10 @@ beforeEach(() => { wsMock.onMessage.mockImplementation(() => () => {}) apiMock.getFreshAgentThreadSnapshot.mockReset() apiMock.getFreshAgentModelCapabilities.mockReset() - apiMock.getFreshAgentTurnPage.mockReset() - apiMock.getFreshAgentTurnBody.mockReset() apiMock.post.mockReset() apiMock.setSessionMetadata.mockReset() apiMock.post.mockResolvedValue({ title: null, source: 'none' }) apiMock.setSessionMetadata.mockResolvedValue(undefined) - apiMock.getFreshAgentTurnPage.mockResolvedValue({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'thread-1', - revision: 1, - nextCursor: null, - turns: [], - bodies: {}, - }) saveServerSettingsPatchSpy.mockClear() window.localStorage.removeItem('freshopencode.modelMru.v2') apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ @@ -1131,7 +1116,7 @@ describe('FreshAgentView', () => { }) }) - it('shows a clear error instead of recreating a legacy freshopencode placeholder', async () => { + it('sends tab restore context when recreating a legacy freshopencode placeholder', async () => { const store = createStore() store.dispatch(updateTab({ id: 'tab-1', @@ -1161,16 +1146,19 @@ describe('FreshAgentView', () => { ) await waitFor(() => { - const content = getFreshAgentPaneContent(store) - expect(content.sessionRef).toBeUndefined() - expect(content.resumeSessionId).toBeUndefined() - expect(content.restoreError).toEqual({ - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', + expect(sentFreshAgentMessages('freshAgent.create').at(-1)).toMatchObject({ + requestId: '-gP4qyCL7bwp8-xbw9G7b', + sessionType: 'freshopencode', + provider: 'opencode', + cwd: '/home/dan/code', + resumeSessionId: 'freshopencode--gP4qyCL7bwp8-xbw9G7b', + legacyRestoreContext: { + title: 'Identifying skills from GitHub repos', + createdAt: 1_781_291_230_743, + updatedAt: expect.any(Number), + }, }) }) - expect(screen.getByRole('alert')).toHaveTextContent('temporary session id') - expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) expect(apiMock.getFreshAgentThreadSnapshot).not.toHaveBeenCalledWith( 'freshopencode', 'opencode', @@ -3708,156 +3696,6 @@ describe('FreshAgentView', () => { })) }) - it('does not auto-title a restored freshcodex pane after an empty metadata snapshot when history has user turns', async () => { - const store = createStore() - apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-restored-title', - status: 'idle', - summary: 'metadata only', - capabilities: { send: true, interrupt: true, fork: true }, - turns: [], - }) - apiMock.getFreshAgentTurnPage.mockResolvedValueOnce({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-restored-title', - revision: 4, - nextCursor: null, - turns: [{ - id: 'turn-existing-user', - turnId: 'turn-existing-user', - role: 'user', - summary: 'Existing request', - items: [{ id: 'item-existing-user', kind: 'text', text: 'Existing request' }], - }], - bodies: {}, - }) - store.dispatch(initLayout({ - tabId: 'tab-1', - paneId: 'pane-1', - content: { - kind: 'fresh-agent', - sessionType: 'freshcodex', - provider: 'codex', - createRequestId: 'req-restored-codex-title', - sessionId: 'codex-restored-title', - sessionRef: { provider: 'codex', sessionId: 'codex-restored-title' }, - status: 'idle', - }, - })) - store.dispatch(updatePaneTitle({ tabId: 'tab-1', paneId: 'pane-1', title: 'Existing Codex pane title', setByUser: false })) - store.dispatch(updateTab({ id: 'tab-1', updates: { title: 'Existing Codex tab title' } })) - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByText('Existing request')).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() - }) - - wsMock.send.mockClear() - fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { - target: { value: 'Do not rename restored codex' }, - }) - fireEvent.click(screen.getByRole('button', { name: 'Send' })) - - await waitFor(() => { - expect(sentFreshAgentMessages('freshAgent.send').at(-1)).toMatchObject({ - sessionId: 'codex-restored-title', - text: 'Do not rename restored codex', - }) - }) - const state = store.getState() - expect(state.panes.paneTitles?.['tab-1']?.['pane-1']).toBe('Existing Codex pane title') - expect(state.tabs.tabs.find((tab) => tab.id === 'tab-1')?.title).toBe('Existing Codex tab title') - }) - - it('refreshes newest restored history instead of retrying an expired older cursor', async () => { - const store = createStore() - apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-stale-cursor', - status: 'idle', - summary: 'metadata only', - capabilities: { send: true, interrupt: true, fork: true }, - turns: [], - }) - apiMock.getFreshAgentTurnPage - .mockResolvedValueOnce({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-stale-cursor', - revision: 1, - nextCursor: 'stale-cursor', - turns: [{ - id: 'turn-newest', - turnId: 'turn-newest', - role: 'assistant', - summary: 'Newest restored turn', - items: [{ id: 'item-newest', kind: 'text', text: 'Newest restored turn' }], - }], - bodies: {}, - }) - .mockRejectedValueOnce(new Error('STALE_THREAD_REVISION latest revision 2')) - .mockResolvedValueOnce({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'codex-stale-cursor', - revision: 2, - nextCursor: null, - turns: [{ - id: 'turn-refreshed', - turnId: 'turn-refreshed', - role: 'assistant', - summary: 'Refreshed newest turn', - items: [{ id: 'item-refreshed', kind: 'text', text: 'Refreshed newest turn' }], - }], - bodies: {}, - }) - store.dispatch(initLayout({ - tabId: 'tab-1', - paneId: 'pane-1', - content: { - kind: 'fresh-agent', - sessionType: 'freshcodex', - provider: 'codex', - createRequestId: 'req-stale-cursor', - sessionId: 'codex-stale-cursor', - sessionRef: { provider: 'codex', sessionId: 'codex-stale-cursor' }, - status: 'idle', - }, - })) - - render( - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Refresh' })) - - await waitFor(() => { - expect(apiMock.getFreshAgentTurnPage).toHaveBeenCalledTimes(3) - }) - expect(apiMock.getFreshAgentTurnPage.mock.calls[1][3]).toMatchObject({ - cursor: 'stale-cursor', - revision: 1, - priority: 'background', - }) - expect(apiMock.getFreshAgentTurnPage.mock.calls[2][3]).toMatchObject({ - cursor: undefined, - priority: 'visible', - includeBodies: true, - }) - }) - it('recreates a lost freshclaude session through fresh-agent transport events with the durable resume id', async () => { const store = createStore() const durableSessionId = '00000000-0000-4000-8000-000000000441' diff --git a/test/unit/client/lib/api.test.ts b/test/unit/client/lib/api.test.ts index 8cb2ca6a2..c1927a661 100644 --- a/test/unit/client/lib/api.test.ts +++ b/test/unit/client/lib/api.test.ts @@ -275,20 +275,8 @@ describe('visible-first read-model helpers', () => { ) }) - it('allows first-page thread-turn requests to omit the restore revision', async () => { - const signal = new AbortController().signal - mockFetch.mockResolvedValueOnce(mockJson({ items: [], nextCursor: null })) - - await getFreshAgentThreadTurns('session-1', { priority: 'visible' }, { signal }) - - expect(mockFetch).toHaveBeenCalledWith( - '/api/fresh-agent/threads/freshclaude/claude/session-1/turns?priority=visible', - expect.objectContaining({ signal, headers: expect.any(Headers) }), - ) - }) - - it('rejects thread-turn cursor requests that omit the pinned restore revision', async () => { - await expect(getFreshAgentThreadTurns('session-1', { priority: 'visible', cursor: 'cursor-1' }, { signal: new AbortController().signal })) + it('rejects thread-turn requests that omit the pinned restore revision', async () => { + await expect(getFreshAgentThreadTurns('session-1', { priority: 'visible' }, { signal: new AbortController().signal })) .rejects .toMatchObject({ name: 'ZodError', diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts index 3fd0e1f39..b34894716 100644 --- a/test/unit/client/store/freshAgentSlice.test.ts +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -2,30 +2,13 @@ import { describe, expect, it } from 'vitest' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import reducer, { createFailed, - freshAgentSnapshotReceived, - historyLoadStarted, - historyPageReceived, registerPendingCreate, - selectFreshAgentTranscriptTurns, sessionCreated, sessionError, sessionInit, sessionSnapshotReceived, setSessionStatus, } from '@/store/freshAgentSlice' -import type { FreshAgentTurn } from '@shared/fresh-agent-contract' - -function turn(turnId: string, summary = turnId, messageId = turnId, ordinal?: number): FreshAgentTurn { - return { - id: turnId, - turnId, - messageId, - ...(ordinal !== undefined ? { ordinal } : {}), - role: 'assistant', - summary, - items: [{ id: `${turnId}-text`, kind: 'text', text: summary }], - } -} describe('freshAgentSlice busy/streaming clearing', () => { const loc = { sessionId: 'abc', sessionType: 'freshclaude' as const, provider: 'claude' as const } @@ -104,121 +87,4 @@ describe('freshAgentSlice', () => { }) expect(state.sessions).toEqual({}) }) - - it('preserves older loaded turns when a newest-page refresh arrives', () => { - const loc = { sessionId: 'thread-1', sessionType: 'freshcodex' as const, provider: 'codex' as const } - const key = makeFreshAgentSessionKey(loc) - let state = reducer(undefined, historyLoadStarted({ ...loc, requestKey: 'first' })) - state = reducer(state, historyPageReceived({ - ...loc, - requestKey: 'first', - turns: [turn('turn-2', 'second'), turn('turn-3', 'third')], - nextCursor: 'cursor-after-newest', - revision: 5, - })) - state = reducer(state, historyPageReceived({ - ...loc, - cursor: 'cursor-after-newest', - turns: [turn('turn-1', 'first')], - nextCursor: 'cursor-after-older', - revision: 5, - })) - - state = reducer(state, historyPageReceived({ - ...loc, - requestKey: 'first', - turns: [turn('turn-3', 'third updated'), turn('turn-4', 'fourth')], - nextCursor: 'cursor-after-newest-refresh', - revision: 5, - })) - - expect(state.sessions[key].historyItems.map((item) => item.turnId)).toEqual([ - 'turn-1', - 'turn-2', - 'turn-3', - 'turn-4', - ]) - expect(state.sessions[key].historyItems[2]?.summary).toBe('third updated') - expect(state.sessions[key].nextHistoryCursor).toBe('cursor-after-older') - }) - - it('ignores stale newest-page responses by request key', () => { - const loc = { sessionId: 'thread-2', sessionType: 'freshcodex' as const, provider: 'codex' as const } - const key = makeFreshAgentSessionKey(loc) - let state = reducer(undefined, historyLoadStarted({ ...loc, requestKey: 'newer' })) - - state = reducer(state, historyPageReceived({ - ...loc, - requestKey: 'older', - turns: [turn('turn-stale')], - nextCursor: null, - revision: 1, - })) - - expect(state.sessions[key].historyItems).toEqual([]) - expect(state.sessions[key].historyLoaded).toBe(false) - }) - - it('keeps older loaded turns but resets the older cursor when the first-page revision changes', () => { - const loc = { sessionId: 'thread-3', sessionType: 'freshcodex' as const, provider: 'codex' as const } - const key = makeFreshAgentSessionKey(loc) - let state = reducer(undefined, historyPageReceived({ - ...loc, - turns: [turn('turn-2')], - nextCursor: 'cursor-v1', - revision: 1, - })) - state = reducer(state, historyPageReceived({ - ...loc, - cursor: 'cursor-v1', - turns: [turn('turn-1')], - nextCursor: 'older-cursor-v1', - revision: 1, - })) - - state = reducer(state, historyPageReceived({ - ...loc, - turns: [turn('turn-2', 'second updated'), turn('turn-3')], - nextCursor: 'cursor-v2', - revision: 2, - })) - - expect(state.sessions[key].historyItems.map((item) => item.turnId)).toEqual(['turn-1', 'turn-2', 'turn-3']) - expect(state.sessions[key].historyRevision).toBe(2) - expect(state.sessions[key].nextHistoryCursor).toBe('cursor-v2') - }) - - it('does not append older snapshot-only turns after a loaded newest page', () => { - const loc = { sessionId: 'thread-order', sessionType: 'freshclaude' as const, provider: 'claude' as const } - const key = makeFreshAgentSessionKey(loc) - let state = reducer(undefined, freshAgentSnapshotReceived({ - hydrateHistory: false, - snapshot: { - sessionType: loc.sessionType, - provider: loc.provider, - threadId: loc.sessionId, - latestTurnId: 'turn-4', - status: 'idle', - revision: 9, - turns: [ - turn('turn-1', 'older one', 'message-1', 1), - turn('turn-2', 'older two', 'message-2', 2), - turn('turn-3', 'newest loaded', 'message-3', 3), - turn('turn-4', 'new live result', 'message-4', 4), - ], - }, - })) - state = reducer(state, historyPageReceived({ - ...loc, - requestKey: 'first-page', - turns: [turn('turn-3', 'newest loaded', 'message-3', 3)], - nextCursor: 'older-cursor', - revision: 9, - })) - - expect(selectFreshAgentTranscriptTurns(state.sessions[key]).map((item) => item.turnId)).toEqual([ - 'turn-3', - 'turn-4', - ]) - }) }) diff --git a/test/unit/client/store/panesPersistence.test.ts b/test/unit/client/store/panesPersistence.test.ts index 82759bf4e..1a5cf76bb 100644 --- a/test/unit/client/store/panesPersistence.test.ts +++ b/test/unit/client/store/panesPersistence.test.ts @@ -168,43 +168,6 @@ describe('Panes Persistence Integration', () => { expect(layoutAfter).toEqual(layoutBefore) }) - it('persists non-durable Freshopencode session refs as clear restore errors', () => { - const store = configureStore({ - reducer: { - tabs: tabsReducer, - panes: panesReducer, - }, - middleware: (getDefault) => getDefault().concat(persistMiddleware as any), - }) - - store.dispatch(addTab({ mode: 'opencode' })) - const tabId = store.getState().tabs.tabs[0].id - store.dispatch(initLayout({ - tabId, - content: { - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - createRequestId: 'req-opencode', - sessionId: 'freshopencode-req-opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-opencode' }, - status: 'connected', - }, - })) - vi.runAllTimers() - - const raw = localStorage.getItem('freshell.layout.v3')! - const persisted = JSON.parse(raw) - const content = persisted.panes.layouts[tabId].content - expect(content.sessionRef).toBeUndefined() - expect(content.sessionId).toBeUndefined() - expect(content.status).toBe('idle') - expect(content.restoreError).toEqual({ - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', - }) - }) - it('initial state loads from localStorage without explicit hydration', () => { // 1. First session: Create state and persist it const store1 = configureStore({ diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts index 4949b4351..4cb2a1f2b 100644 --- a/test/unit/client/store/panesSlice.test.ts +++ b/test/unit/client/store/panesSlice.test.ts @@ -463,35 +463,6 @@ describe('panesSlice', () => { expect(leaf.content.resumeSessionId).toBeUndefined() }) - it('marks invalid fresh-agent restore targets idle instead of connected', () => { - const state = panesReducer( - initialState, - initLayout({ - tabId: 'tab-1', - content: { - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - sessionId: 'freshopencode-req-opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-opencode' }, - createRequestId: 'req-opencode', - status: 'connected', - }, - }), - ) - - const leaf = state.layouts['tab-1'] as Extract - expect(leaf.content).toMatchObject({ - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - status: 'idle', - restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'invalid_legacy_restore_target' }, - }) - expect(leaf.content.sessionRef).toBeUndefined() - expect(leaf.content.resumeSessionId).toBeUndefined() - }) - it('preserves fresh-agent style through content initialization and merge updates', () => { const state = panesReducer(undefined, initLayout({ tabId: 'tab-style', diff --git a/test/unit/client/store/tabsSlice.test.ts b/test/unit/client/store/tabsSlice.test.ts index e9d57a8b6..f59592c63 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -811,7 +811,7 @@ describe('tabsSlice', () => { const store = createOpenSessionStore() await store.dispatch(openSessionTab({ - sessionId: 'ses_opencode_1', + sessionId: 'opencode-session-1', cwd: '/repo', provider: 'opencode', sessionType: 'freshopencode', @@ -820,7 +820,7 @@ describe('tabsSlice', () => { const state = store.getState() const tab = state.tabs.tabs.find((candidate) => candidate.title === 'repo') expect(tab).toBeTruthy() - expect(tab?.sessionRef).toEqual({ provider: 'opencode', sessionId: 'ses_opencode_1' }) + expect(tab?.sessionRef).toEqual({ provider: 'opencode', sessionId: 'opencode-session-1' }) const layout = state.panes.layouts[tab!.id] expect(layout.type).toBe('leaf') @@ -828,7 +828,7 @@ describe('tabsSlice', () => { kind: 'fresh-agent', sessionType: 'freshopencode', provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'ses_opencode_1' }, + sessionRef: { provider: 'opencode', sessionId: 'opencode-session-1' }, }) expect(layout.content).not.toHaveProperty('resumeSessionId') }) diff --git a/test/unit/server/agent-api/layout-store-fresh-agent.test.ts b/test/unit/server/agent-api/layout-store-fresh-agent.test.ts index e94626a6f..848742a6a 100644 --- a/test/unit/server/agent-api/layout-store-fresh-agent.test.ts +++ b/test/unit/server/agent-api/layout-store-fresh-agent.test.ts @@ -9,10 +9,10 @@ describe('LayoutStore fresh-agent content', () => { kind: 'fresh-agent', sessionType: 'freshopencode', provider: 'opencode', - sessionId: 'ses_opencode_1', + sessionId: 'freshopencode-req-1', createRequestId: 'req-1', status: 'connected', - sessionRef: { provider: 'opencode', sessionId: 'ses_opencode_1' }, + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-1' }, initialCwd: '/repo', model: 'umans-ai-coding-plan/umans-kimi-k2.7', effort: 'high', @@ -22,46 +22,8 @@ describe('LayoutStore fresh-agent content', () => { expect(snap?.kind).toBe('fresh-agent') expect(snap?.paneContent).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshopencode', provider: 'opencode', - sessionId: 'ses_opencode_1', createRequestId: 'req-1', - sessionRef: { provider: 'opencode', sessionId: 'ses_opencode_1' }, + sessionId: 'freshopencode-req-1', createRequestId: 'req-1', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-1' }, }) }) - - it('normalizes legacy freshopencode placeholders when a UI layout snapshot is stored', () => { - const store = new LayoutStore() - store.updateFromUi({ - tabs: [{ id: 'tab-1', title: 'OpenCode' }], - activeTabId: 'tab-1', - activePane: { 'tab-1': 'pane-1' }, - layouts: { - 'tab-1': { - type: 'leaf', - id: 'pane-1', - content: { - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - sessionId: 'freshopencode-req-legacy', - resumeSessionId: 'freshopencode-req-legacy', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-legacy' }, - status: 'connected', - }, - }, - }, - }, 'conn-1') - - const snap = store.getPaneSnapshot('pane-1') - expect(snap?.paneContent).toMatchObject({ - kind: 'fresh-agent', - sessionType: 'freshopencode', - provider: 'opencode', - status: 'idle', - restoreError: { - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', - }, - }) - expect(snap?.paneContent?.sessionRef).toBeUndefined() - expect(snap?.paneContent?.resumeSessionId).toBeUndefined() - }) }) diff --git a/test/unit/server/fresh-agent/claude-history-include-bodies.test.ts b/test/unit/server/fresh-agent/claude-history-include-bodies.test.ts index 8c05535bc..ca13b9b8c 100644 --- a/test/unit/server/fresh-agent/claude-history-include-bodies.test.ts +++ b/test/unit/server/fresh-agent/claude-history-include-bodies.test.ts @@ -78,12 +78,6 @@ describe('FreshAgentThreadTurnsQuerySchema includeBodies parsing', () => { expect(result.includeBodies).toBeUndefined() }) - it('allows initial pages to omit revision but requires revision with a cursor', () => { - expect(FreshAgentThreadTurnsQuerySchema.parse({ priority: 'visible' }).revision).toBeUndefined() - expect(() => FreshAgentThreadTurnsQuerySchema.parse({ priority: 'visible', cursor: 'cursor-1' })).toThrow(/revision/) - expect(FreshAgentThreadTurnsQuerySchema.parse({ priority: 'visible', cursor: 'cursor-1', revision: 7 }).revision).toBe(7) - }) - it('rejects invalid string values for includeBodies', () => { expect(() => FreshAgentThreadTurnsQuerySchema.parse({ includeBodies: 'abc', priority: 'visible', revision: 7 })).toThrow() expect(() => FreshAgentThreadTurnsQuerySchema.parse({ includeBodies: '0', priority: 'visible', revision: 7 })).toThrow() diff --git a/test/unit/server/fresh-agent/claude-history-service.test.ts b/test/unit/server/fresh-agent/claude-history-service.test.ts index 8bb7312ba..1ae9f78c9 100644 --- a/test/unit/server/fresh-agent/claude-history-service.test.ts +++ b/test/unit/server/fresh-agent/claude-history-service.test.ts @@ -51,7 +51,7 @@ function toResolvedHistory(sessionId: string, timelineSessionId: string | undefi } describe('Claude fresh-agent history service', () => { - it('returns the newest timeline chunk in reading order with a cursor for older turns', async () => { + it('returns recent-first timeline pages with a cursor', async () => { const resolve = vi.fn().mockResolvedValue({ ...toResolvedHistory('agent-session-1', undefined), }) @@ -67,8 +67,8 @@ describe('Claude fresh-agent history service', () => { }) expect(firstPage.items.map((item) => item.summary)).toEqual([ - 'middle assistant turn', 'latest user turn', + 'middle assistant turn', ]) expect(firstPage.nextCursor).toBeTruthy() expect(resolve).toHaveBeenCalledWith('agent-session-1') @@ -228,25 +228,7 @@ describe('Claude fresh-agent history service', () => { }) }) - it('allows initial timeline-page reads to omit the restore revision', async () => { - const service = createClaudeFreshAgentHistoryService({ - agentHistorySource: { - resolve: vi.fn().mockResolvedValue({ - ...toResolvedHistory('sdk-1', '00000000-0000-4000-8000-000000000001'), - revision: 13, - }), - }, - }) - - await expect(service.getThreadTurnPage({ - sessionId: 'sdk-1', - priority: 'visible', - })).resolves.toMatchObject({ - revision: 13, - }) - }) - - it('rejects timeline-page cursor reads that omit the accepted restore revision', async () => { + it('rejects timeline-page reads that omit the accepted restore revision', async () => { const service = createClaudeFreshAgentHistoryService({ agentHistorySource: { resolve: vi.fn().mockResolvedValue({ @@ -256,17 +238,9 @@ describe('Claude fresh-agent history service', () => { }, }) - const firstPage = await service.getThreadTurnPage({ - sessionId: 'sdk-1', - priority: 'visible', - limit: 1, - revision: 13, - }) - await expect(service.getThreadTurnPage({ sessionId: 'sdk-1', priority: 'visible', - cursor: firstPage.nextCursor ?? undefined, } as any)).rejects.toThrow('Restore revision is required') }) diff --git a/test/unit/server/fresh-agent/codex-adapter.test.ts b/test/unit/server/fresh-agent/codex-adapter.test.ts index e1a8af68e..f2307ed8d 100644 --- a/test/unit/server/fresh-agent/codex-adapter.test.ts +++ b/test/unit/server/fresh-agent/codex-adapter.test.ts @@ -35,14 +35,14 @@ function makeCodexThread(id: string) { } } -function makeCodexTurn(id: string, text = 'Codex summary') { +function makeCodexTurn(id: string) { return { id, status: 'completed', items: [{ type: 'agentMessage', id: `${id}:item-1`, - text, + text: 'Codex summary', phase: null, memoryCitation: null, }], @@ -96,7 +96,7 @@ describe('Codex fresh-agent adapter', () => { }, { revision: 7, limit: 1 }) expect(firstPage.turns).toHaveLength(1) - expect(firstPage.turns[0]).toMatchObject({ role: 'assistant', summary: 'Checking changes' }) + expect(firstPage.turns[0]).toMatchObject({ role: 'user', summary: 'Review the diff.' }) expect(firstPage.nextCursor).toMatch(/^codex-cursor:v1:[A-Za-z0-9_-]+$/) expect(firstPage.nextCursor).not.toContain('provider-after-turn-1') expect(firstPage.nextCursor).not.toContain('turn-1') @@ -109,7 +109,7 @@ describe('Codex fresh-agent adapter', () => { }, { revision: 7, limit: 1, cursor: firstPage.nextCursor }) expect(secondPage.turns).toHaveLength(1) - expect(secondPage.turns[0]).toMatchObject({ role: 'user', summary: 'Review the diff.' }) + expect(secondPage.turns[0]).toMatchObject({ role: 'assistant', summary: 'Checking changes' }) expect(secondPage.turns[0].turnId).not.toBe(firstPage.turns[0].turnId) expect(runtime.listThreadTurns).toHaveBeenCalledTimes(1) @@ -127,66 +127,12 @@ describe('Codex fresh-agent adapter', () => { threadId: 'thread-new-1', limit: 1, itemsView: 'full', - sortDirection: 'desc', }) expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(2, { threadId: 'thread-new-1', cursor: 'provider-after-turn-1', limit: 1, itemsView: 'full', - sortDirection: 'desc', - }) - }) - - it('returns newest Codex provider pages in reading order and uses the cursor for older turns', async () => { - const runtime = { - startThread: vi.fn(), - resumeThread: vi.fn(), - readThread: vi.fn(), - listThreadTurns: vi.fn() - .mockResolvedValueOnce({ revision: 7, nextCursor: 'provider-after-newest', turns: [makeCodexTurn('turn-3', 'newest')] }) - .mockResolvedValueOnce({ revision: 7, nextCursor: 'provider-after-middle', turns: [makeCodexTurn('turn-2', 'middle')] }) - .mockResolvedValueOnce({ revision: 7, nextCursor: null, turns: [makeCodexTurn('turn-1', 'oldest')] }), - readThreadTurn: vi.fn(), - } - const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - - const firstPage: any = await adapter.getTurnPage?.({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'thread-new-1', - }, { revision: 7, limit: 2 }) - - expect(firstPage.turns.map((turn: any) => turn.summary)).toEqual(['middle', 'newest']) - expect(firstPage.nextCursor).toMatch(/^codex-cursor:v1:[A-Za-z0-9_-]+$/) - - const secondPage: any = await adapter.getTurnPage?.({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'thread-new-1', - }, { revision: 7, limit: 2, cursor: firstPage.nextCursor }) - - expect(secondPage.turns.map((turn: any) => turn.summary)).toEqual(['oldest']) - expect(secondPage.nextCursor).toBeNull() - expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(1, { - threadId: 'thread-new-1', - limit: 1, - itemsView: 'full', - sortDirection: 'desc', - }) - expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(2, { - threadId: 'thread-new-1', - cursor: 'provider-after-newest', - limit: 1, - itemsView: 'full', - sortDirection: 'desc', - }) - expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(3, { - threadId: 'thread-new-1', - cursor: 'provider-after-middle', - limit: 1, - itemsView: 'full', - sortDirection: 'desc', }) }) @@ -210,7 +156,7 @@ describe('Codex fresh-agent adapter', () => { threadId: 'thread-new-1', }, { revision: 7, limit: 1 }) expect(firstPage.turns).toHaveLength(1) - expect(firstPage.turns[0]).toMatchObject({ role: 'assistant' }) + expect(firstPage.turns[0]).toMatchObject({ role: 'user' }) const secondPage: any = await adapter.getTurnPage?.({ sessionType: 'freshcodex', @@ -219,7 +165,7 @@ describe('Codex fresh-agent adapter', () => { }, { revision: 7, limit: 30, cursor: firstPage.nextCursor }) expect(secondPage.turns).toHaveLength(1) - expect(secondPage.turns[0]).toMatchObject({ role: 'user' }) + expect(secondPage.turns[0]).toMatchObject({ role: 'assistant' }) expect(secondPage.turns.map((turn: any) => turn.turnId)).not.toContain(firstPage.turns[0].turnId) expect(secondPage.nextCursor).toBeNull() expect(runtime.listThreadTurns).toHaveBeenCalledTimes(1) @@ -247,7 +193,7 @@ describe('Codex fresh-agent adapter', () => { expect(page.turns).toHaveLength(1) expect(Object.keys(page.bodies)).toEqual([page.turns[0].turnId]) - expect(page.bodies[page.turns[0].turnId]).toMatchObject({ role: 'assistant' }) + expect(page.bodies[page.turns[0].turnId]).toMatchObject({ role: 'user' }) expect(page.bodies).not.toHaveProperty('turn-1') }) @@ -386,14 +332,12 @@ describe('Codex fresh-agent adapter', () => { threadId: 'thread-new-1', limit: 100, itemsView: 'full', - sortDirection: 'desc', }) expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(2, { threadId: 'thread-new-1', cursor: 'provider-page-2', limit: 100, itemsView: 'full', - sortDirection: 'desc', }) }) @@ -535,9 +479,15 @@ describe('Codex fresh-agent adapter', () => { provider: 'codex', threadId: 'thread-new-1', revision: 7, - turns: [], }) - expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: false }) + expect(snapshot.turns).toHaveLength(2) + expect(snapshot.turns[0]).toMatchObject({ role: 'user', ordinal: 0 }) + expect(snapshot.turns[1]).toMatchObject({ role: 'assistant', ordinal: 1 }) + expect(snapshot.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[1].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[0].turnId).not.toContain('turn-1') + expect(snapshot.turns[0]).not.toHaveProperty('providerTurnId') + expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: true }) const page: any = await adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7 }) expect(page).toMatchObject({ revision: 7, @@ -546,23 +496,11 @@ describe('Codex fresh-agent adapter', () => { expect.objectContaining({ role: 'assistant' }), ], }) - expect(page.turns[0]).toMatchObject({ role: 'user', ordinal: 0 }) - expect(page.turns[1]).toMatchObject({ role: 'assistant', ordinal: 1 }) - expect(page.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) - expect(page.turns[1].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) - expect(page.turns[0].turnId).not.toContain('turn-1') - expect(page.turns[0]).not.toHaveProperty('providerTurnId') expect(page.turns[1].items).toEqual([ expect.objectContaining({ kind: 'reasoning' }), expect.objectContaining({ kind: 'text', text: 'The patch is safe.' }), ]) expect(page.bodies[page.turns[1].turnId]).toMatchObject({ role: 'assistant' }) - expect(runtime.listThreadTurns).toHaveBeenCalledWith({ - threadId: 'thread-new-1', - limit: 1, - itemsView: 'full', - sortDirection: 'desc', - }) await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: page.turns[1].turnId }, 7)).resolves.toMatchObject({ turnId: page.turns[1].turnId, revision: 7, @@ -578,31 +516,6 @@ describe('Codex fresh-agent adapter', () => { }) }) - it('tracks an active Codex turn from metadata-only snapshots', async () => { - const runtime = { - startThread: vi.fn(), - resumeThread: vi.fn(), - readThread: vi.fn().mockResolvedValue({ - thread: { - ...makeCodexThread('thread-active-1'), - status: { type: 'active', activeFlags: [], activeTurnId: 'turn-active-1' }, - turns: [], - }, - }), - listThreadTurns: vi.fn(), - readThreadTurn: vi.fn(), - interruptTurn: vi.fn().mockResolvedValue(undefined), - } - const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - - await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-active-1' }, 7) - await adapter.interrupt?.('thread-active-1') - - expect(runtime.readThread).toHaveBeenCalledTimes(1) - expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-active-1', includeTurns: false }) - expect(runtime.interruptTurn).toHaveBeenCalledWith({ threadId: 'thread-active-1', turnId: 'turn-active-1' }) - }) - it('keeps display ids short and opaque for long native ids and item ids', async () => { const longProviderId = `turn-${'native-id-'.repeat(40)}` const longItemId = `item-${'item-id-'.repeat(40)}` @@ -620,29 +533,21 @@ describe('Codex fresh-agent adapter', () => { }], }, }), - listThreadTurns: vi.fn().mockResolvedValue({ - revision: 11, - nextCursor: null, - turns: [{ - id: longProviderId, - status: 'completed', - items: [{ type: 'agentMessage', id: longItemId, text: 'Short public id' }], - }], - }), + listThreadTurns: vi.fn(), readThreadTurn: vi.fn(), } const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - const page: any = await adapter.getTurnPage?.({ + const snapshot: any = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-long-ids', - }, { revision: 11 }) + }, 11) - expect(page.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) - expect(page.turns[0].turnId.length).toBeLessThan(45) - expect(page.turns[0].turnId).not.toContain(longProviderId.slice(0, 20)) - expect(page.turns[0].turnId).not.toContain(longItemId.slice(0, 20)) + expect(snapshot.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[0].turnId.length).toBeLessThan(45) + expect(snapshot.turns[0].turnId).not.toContain(longProviderId.slice(0, 20)) + expect(snapshot.turns[0].turnId).not.toContain(longItemId.slice(0, 20)) }) it('does not pass unknown or malformed display ids to Codex body reads', async () => { @@ -762,28 +667,7 @@ describe('Codex fresh-agent adapter', () => { }], }, }), - listThreadTurns: vi.fn() - .mockResolvedValueOnce({ - revision: 8, - nextCursor: null, - turns: [{ - id: 'turn-submitted-1', - status: 'inProgress', - items: [{ type: 'agentMessage', id: 'assistant-1', text: 'Working on it.' }], - }], - }) - .mockResolvedValueOnce({ - revision: 9, - nextCursor: null, - turns: [{ - id: 'turn-submitted-1', - status: 'completed', - items: [ - { type: 'userMessage', id: 'real-user-1', content: [{ type: 'text', text: 'Review this image' }] }, - { type: 'agentMessage', id: 'assistant-1', text: 'Done.' }, - ], - }], - }), + listThreadTurns: vi.fn(), readThreadTurn: vi.fn(), } const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) @@ -809,28 +693,28 @@ describe('Codex fresh-agent adapter', () => { ], })) - const pendingPage: any = await adapter.getTurnPage?.({ + const pendingSnapshot: any = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', - }, { revision: 8 }) - expect(pendingPage.turns[0]).toMatchObject({ + }, 8) + expect(pendingSnapshot.turns[0]).toMatchObject({ turnId: sendResult.submittedTurnId, role: 'user', source: 'durable', }) - expect(pendingPage.turns[0].summary).toBe('Review this image') + expect(pendingSnapshot.turns[0].summary).toBe('Review this image') - const materializedPage: any = await adapter.getTurnPage?.({ + const materializedSnapshot: any = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', - }, { revision: 9 }) - expect(materializedPage.turns[0]).toMatchObject({ + }, 9) + expect(materializedSnapshot.turns[0]).toMatchObject({ turnId: sendResult.submittedTurnId, role: 'user', }) - expect(materializedPage.turns.filter((turn: any) => turn.role === 'user')).toHaveLength(1) + expect(materializedSnapshot.turns.filter((turn: any) => turn.role === 'user')).toHaveLength(1) }) it('keeps same-text queued submitted rows distinct by request id', async () => { @@ -858,38 +742,20 @@ describe('Codex fresh-agent adapter', () => { ], }, }), - listThreadTurns: vi.fn() - .mockResolvedValueOnce({ - revision: 8, - nextCursor: 'after-submitted-2', - turns: [{ - id: 'turn-submitted-2', - status: 'inProgress', - items: [{ type: 'agentMessage', id: 'assistant-2', text: 'Still working.' }], - }], - }) - .mockResolvedValueOnce({ - revision: 8, - nextCursor: null, - turns: [{ - id: 'turn-submitted-1', - status: 'inProgress', - items: [{ type: 'agentMessage', id: 'assistant-1', text: 'Working.' }], - }], - }), + listThreadTurns: vi.fn(), readThreadTurn: vi.fn(), } const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) const first: any = await adapter.send?.('thread-new-1', { requestId: 'send-1', text: 'Same prompt' }) const second: any = await adapter.send?.('thread-new-1', { requestId: 'send-2', text: 'Same prompt' }) - const page: any = await adapter.getTurnPage?.({ + const snapshot: any = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', - }, { revision: 8 }) + }, 8) - const userRows = page.turns.filter((turn: any) => turn.role === 'user') + const userRows = snapshot.turns.filter((turn: any) => turn.role === 'user') expect(first.submittedTurnId).not.toBe(second.submittedTurnId) expect(userRows.map((turn: any) => turn.turnId)).toEqual([first.submittedTurnId, second.submittedTurnId]) }) @@ -901,9 +767,11 @@ describe('Codex fresh-agent adapter', () => { wsUrl: 'ws://127.0.0.1:43123', }), resumeThread: vi.fn(), - readThread: vi.fn().mockResolvedValue({ - thread: makeCodexThread('thread-empty-1'), - }), + readThread: vi.fn() + .mockRejectedValueOnce(new Error('Codex app-server thread/read failed: thread thread-empty-1 is not materialized yet; includeTurns is unavailable before first user message')) + .mockResolvedValueOnce({ + thread: makeCodexThread('thread-empty-1'), + }), listThreadTurns: vi.fn(), readThreadTurn: vi.fn(), } @@ -925,8 +793,8 @@ describe('Codex fresh-agent adapter', () => { turns: [], }) - expect(runtime.readThread).toHaveBeenCalledTimes(1) - expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-empty-1', includeTurns: false }) + expect(runtime.readThread).toHaveBeenNthCalledWith(1, { threadId: 'thread-empty-1', includeTurns: true }) + expect(runtime.readThread).toHaveBeenNthCalledWith(2, { threadId: 'thread-empty-1', includeTurns: false }) }) it('lazily resumes a Codex runtime before reading a persisted thread after server reload', async () => { @@ -956,7 +824,7 @@ describe('Codex fresh-agent adapter', () => { }) expect(runtime.resumeThread).toHaveBeenCalledWith({ threadId: 'thread-existing-1' }) - expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-existing-1', includeTurns: false }) + expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-existing-1', includeTurns: true }) await adapter.shutdown?.() expect(runtime.shutdown).toHaveBeenCalledTimes(1) @@ -1121,17 +989,7 @@ describe('Codex fresh-agent adapter', () => { turns: [makeCodexTurn('turn-1'), makeCodexTurn('turn-2')], }, }), - listThreadTurns: vi.fn() - .mockResolvedValueOnce({ - revision: 7, - nextCursor: 'after-turn-2', - turns: [makeCodexTurn('turn-2')], - }) - .mockResolvedValueOnce({ - revision: 7, - nextCursor: null, - turns: [makeCodexTurn('turn-1')], - }), + listThreadTurns: vi.fn(), readThreadTurn: vi.fn(), } const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) @@ -1148,15 +1006,15 @@ describe('Codex fresh-agent adapter', () => { settings: { model: 'gpt-5.4-flash' }, }) - const page = await adapter.getTurnPage?.({ + const snapshot = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', - }, { revision: 7 }) as any - expect(page.turns).toHaveLength(3) - expect(page.turns[0]).not.toHaveProperty('model') - expect(page.turns[1]).toMatchObject({ role: 'user', model: 'gpt-5.4-flash' }) - expect(page.turns[2]).toMatchObject({ role: 'assistant', model: 'gpt-5.4-flash' }) + }, 7) as any + expect(snapshot.turns).toHaveLength(3) + expect(snapshot.turns[0]).not.toHaveProperty('model') + expect(snapshot.turns[1]).toMatchObject({ role: 'user', model: 'gpt-5.4-flash' }) + expect(snapshot.turns[2]).toMatchObject({ role: 'assistant', model: 'gpt-5.4-flash' }) }) it('subscribes to Codex lifecycle notifications and projects matching thread updates', async () => { diff --git a/test/unit/server/fresh-agent/opencode-history-query.test.ts b/test/unit/server/fresh-agent/opencode-history-query.test.ts new file mode 100644 index 000000000..756a888c9 --- /dev/null +++ b/test/unit/server/fresh-agent/opencode-history-query.test.ts @@ -0,0 +1,675 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + encodeOpencodeCursor, + resolveOpencodeLegacySession, + readOpencodeSessionInfo, + readOpencodeSnapshotPage, + readOpencodeTurnBody, + readOpencodeTurnPage, +} from '../../../../server/fresh-agent/adapters/opencode/history-query.js' + +vi.unmock('node:sqlite') + +type SqliteModule = typeof import('node:sqlite') +type DatabaseSyncConstructor = SqliteModule['DatabaseSync'] +type DatabaseSyncInstance = InstanceType + +const sessionId = 'ses_opencode_db_history' +const otherSessionId = 'ses_other_opencode_db_history' +const baseTime = 1_779_557_095_000 +const modelFixture = { + providerID: 'opencode-go', + id: 'deepseek-v4-flash', + variant: 'max', +} + +describe('OpenCode DB history query', () => { + let tempDir: string + let DatabaseSync: DatabaseSyncConstructor + + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-history-query-')) + DatabaseSync = (await import('node:sqlite')).DatabaseSync + }) + + afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) + }) + + function createDatabase(name = 'opencode.db'): DatabaseSyncInstance { + return new DatabaseSync(path.join(tempDir, name)) + } + + function instrumentReadDatabase(db: DatabaseSyncInstance): { reader: Parameters[0]; events: string[] } { + const events: string[] = [] + return { + events, + reader: { + exec(sql: string) { + events.push(sql.trim()) + return db.exec(sql) + }, + prepare(sql: string) { + events.push(`PREPARE:${sql.trim()}`) + return db.prepare(sql) as any + }, + }, + } + } + + function createOpenCodeSchema(db: DatabaseSyncInstance): void { + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + version text NOT NULL, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + metadata text, + cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + revert text, + permission text, + agent text, + model text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_compacting integer, + time_archived integer + ); + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) + } + + function insertSession(db: DatabaseSyncInstance, overrides: { + id?: string + directory?: string + title?: string + model?: unknown + timeCreated?: number + timeUpdated?: number + } = {}): void { + db.prepare(` + INSERT INTO session ( + id, + project_id, + workspace_id, + parent_id, + slug, + directory, + path, + title, + version, + share_url, + summary_additions, + summary_deletions, + summary_files, + summary_diffs, + metadata, + cost, + tokens_input, + tokens_output, + tokens_reasoning, + tokens_cache_read, + tokens_cache_write, + revert, + permission, + agent, + model, + time_created, + time_updated, + time_compacting, + time_archived + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + overrides.id ?? sessionId, + 'project-1', + 'workspace-1', + null, + 'opencode-db-history', + overrides.directory ?? '/repo/opencode', + '/repo/opencode/.opencode/session', + overrides.title ?? 'Review DB history reads', + '1.16.2', + null, + 7, + 2, + 3, + JSON.stringify([{ file: 'src/app.ts', additions: 7, deletions: 2 }]), + JSON.stringify({ source: 'unit-test' }), + 0.42, + 111, + 222, + 33, + 44, + 55, + null, + 'ask', + 'build', + JSON.stringify(overrides.model ?? modelFixture), + overrides.timeCreated ?? baseTime, + overrides.timeUpdated ?? baseTime + 9_000, + null, + null, + ) + } + + function insertMessage( + db: DatabaseSyncInstance, + messageId: string, + createdOffset: number, + data: Record, + targetSessionId = sessionId, + ): void { + const createdAt = baseTime + createdOffset + db.prepare(` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?) + `).run(messageId, targetSessionId, createdAt, createdAt + 17, JSON.stringify(data)) + } + + function insertPart( + db: DatabaseSyncInstance, + partId: string, + messageId: string, + data: Record, + targetSessionId = sessionId, + ): void { + db.prepare(` + INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?) + `).run(partId, messageId, targetSessionId, baseTime + 50_000, baseTime + 50_017, JSON.stringify(data)) + } + + function seedConversation(db: DatabaseSyncInstance): void { + createOpenCodeSchema(db) + insertSession(db) + + insertMessage(db, 'message-1', 1_000, { role: 'user' }) + insertPart(db, 'part-1', 'message-1', { type: 'text', text: 'Summarize the project state.' }) + + insertMessage(db, 'message-2', 2_000, { + role: 'assistant', + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + }) + insertPart(db, 'part-2', 'message-2', { type: 'text', text: 'The project is ready for a DB reader.' }) + + insertMessage(db, 'message-3', 3_000, { role: 'user' }) + insertPart(db, 'part-3', 'message-3', { + type: 'file', + path: 'server/fresh-agent/adapters/opencode/history-query.ts', + mime: 'text/typescript', + content: 'export {}', + }) + + insertMessage(db, 'message-4', 4_000, { + role: 'assistant', + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + }) + insertPart(db, 'part-4', 'message-4', { + type: 'patch', + files: [{ path: 'server/fresh-agent/adapters/opencode/history-query.ts', additions: 12, deletions: 0 }], + diff: 'diff --git a/history-query.ts b/history-query.ts', + }) + + insertMessage(db, 'message-5', 5_000, { role: 'assistant' }) + insertPart(db, 'part-5', 'message-5', { + type: 'compaction', + summary: 'Compacted earlier DB history context.', + beforeTokens: 10_000, + afterTokens: 3_500, + }) + } + + it('reads session info with parsed model JSON, token fields, and millisecond timestamps', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db, { + title: 'Typed session metadata', + directory: '/workspace/project', + timeCreated: baseTime + 101, + timeUpdated: baseTime + 202, + }) + + const info = readOpencodeSessionInfo(db, { sessionId }) + + expect(info).toMatchObject({ + id: sessionId, + directory: '/workspace/project', + title: 'Typed session metadata', + model: modelFixture, + tokens: { + input: 111, + output: 222, + reasoning: 33, + cache: { + read: 44, + write: 55, + }, + }, + cost: 0.42, + time: { + created: baseTime + 101, + updated: baseTime + 202, + }, + }) + expect(typeof info.model).toBe('object') + } finally { + db.close() + } + }) + + it('wraps schema inspection and reads in a transaction for each public read helper', () => { + const db = createDatabase() + try { + seedConversation(db) + const { reader, events } = instrumentReadDatabase(db) + const reads: Array<[string, () => unknown]> = [ + ['session info', () => readOpencodeSessionInfo(reader, { sessionId })], + ['snapshot page', () => readOpencodeSnapshotPage(reader, { sessionId, limit: 2 })], + ['turn page', () => readOpencodeTurnPage(reader, { sessionId, limit: 2 })], + ['turn body', () => readOpencodeTurnBody(reader, { sessionId, turnId: 'message-4' })], + ] + + for (const [_label, read] of reads) { + events.length = 0 + read() + const beginIndex = events.indexOf('BEGIN') + const commitIndex = events.indexOf('COMMIT') + const firstSchemaPrepareIndex = events.findIndex((event) => event.startsWith('PREPARE:PRAGMA table_info')) + + expect(beginIndex).toBe(0) + expect(firstSchemaPrepareIndex).toBeGreaterThan(beginIndex) + expect(commitIndex).toBeGreaterThan(firstSchemaPrepareIndex) + } + } finally { + db.close() + } + }) + + it('reads the newest snapshot messages in chronological order and reports older history', () => { + const db = createDatabase() + try { + seedConversation(db) + + const page = readOpencodeSnapshotPage(db, { sessionId, limit: 3 }) + + expect(page.info).toMatchObject({ id: sessionId, title: 'Review DB history reads' }) + expect(page.messages.map((message) => message.info.id)).toEqual(['message-3', 'message-4', 'message-5']) + expect(page.messages.map((message) => message.info.time.created)).toEqual([ + baseTime + 3_000, + baseTime + 4_000, + baseTime + 5_000, + ]) + expect(page.hasMoreBefore).toBe(true) + } finally { + db.close() + } + }) + + it('loads the newest turn page first and uses an opaque cursor for older history', () => { + const db = createDatabase() + try { + seedConversation(db) + + const first = readOpencodeTurnPage(db, { sessionId, limit: 2 }) + const expectedCursor = encodeOpencodeCursor({ timeCreated: baseTime + 4_000, id: 'message-4' }) + + expect(first.messages.map((message) => message.info.id)).toEqual(['message-4', 'message-5']) + expect(first.nextCursor).toBe(expectedCursor) + expect(first.nextCursor).not.toContain('message-4') + expect(first.hasMoreBefore).toBe(true) + + const second = readOpencodeTurnPage(db, { sessionId, limit: 2, cursor: first.nextCursor }) + expect(second.messages.map((message) => message.info.id)).toEqual(['message-2', 'message-3']) + expect(second.nextCursor).toEqual(expect.any(String)) + expect(second.nextCursor).not.toEqual(first.nextCursor) + + const third = readOpencodeTurnPage(db, { sessionId, limit: 2, cursor: second.nextCursor }) + expect(third.messages.map((message) => message.info.id)).toEqual(['message-1']) + expect(third.nextCursor).toBeNull() + expect(third.hasMoreBefore).toBe(false) + } finally { + db.close() + } + }) + + it('uses message id as a cursor tie-breaker when messages share a timestamp', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db) + insertMessage(db, 'message-a', 1_000, { role: 'user' }) + insertPart(db, 'part-a', 'message-a', { type: 'text', text: 'First message.' }) + insertMessage(db, 'message-b', 2_000, { role: 'assistant' }) + insertPart(db, 'part-b', 'message-b', { type: 'text', text: 'First duplicate timestamp message.' }) + insertMessage(db, 'message-c', 2_000, { role: 'user' }) + insertPart(db, 'part-c', 'message-c', { type: 'text', text: 'Second duplicate timestamp message.' }) + insertMessage(db, 'message-d', 3_000, { role: 'assistant' }) + insertPart(db, 'part-d', 'message-d', { type: 'text', text: 'Final message.' }) + + const first = readOpencodeTurnPage(db, { sessionId, limit: 2 }) + const expectedCursor = encodeOpencodeCursor({ timeCreated: baseTime + 2_000, id: 'message-c' }) + + expect(first.messages.map((message) => message.info.id)).toEqual(['message-c', 'message-d']) + expect(first.nextCursor).toBe(expectedCursor) + + const second = readOpencodeTurnPage(db, { sessionId, limit: 2, cursor: first.nextCursor }) + expect(second.messages.map((message) => message.info.id)).toEqual(['message-a', 'message-b']) + expect(second.nextCursor).toBeNull() + + const pagedIds = [...second.messages, ...first.messages].map((message) => message.info.id) + expect(pagedIds).toEqual(['message-a', 'message-b', 'message-c', 'message-d']) + expect(new Set(pagedIds).size).toBe(pagedIds.length) + } finally { + db.close() + } + }) + + it('isolates snapshot, page, and body reads to the requested session', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db, { title: 'Primary session' }) + insertSession(db, { + id: otherSessionId, + title: 'Interleaved other session', + directory: '/repo/other-opencode', + timeCreated: baseTime + 500, + timeUpdated: baseTime + 3_500, + }) + + insertMessage(db, 'primary-message-1', 1_000, { role: 'user' }) + insertPart(db, 'primary-part-1', 'primary-message-1', { type: 'text', text: 'Primary first.' }) + insertMessage(db, 'other-message-1', 1_500, { role: 'assistant' }, otherSessionId) + insertPart(db, 'other-part-1', 'other-message-1', { type: 'text', text: 'Other first.' }, otherSessionId) + insertMessage(db, 'primary-message-2', 2_000, { role: 'assistant' }) + insertPart(db, 'primary-part-2', 'primary-message-2', { type: 'text', text: 'Primary second.' }) + insertMessage(db, 'other-message-2', 2_500, { role: 'user' }, otherSessionId) + insertPart(db, 'other-part-2', 'other-message-2', { type: 'text', text: 'Other second.' }, otherSessionId) + + const snapshot = readOpencodeSnapshotPage(db, { sessionId, limit: 10 }) + expect(snapshot.messages.map((message) => message.info.id)).toEqual(['primary-message-1', 'primary-message-2']) + expect(snapshot.messages.flatMap((message) => message.parts).map((part) => part.id)).toEqual([ + 'primary-part-1', + 'primary-part-2', + ]) + + const page = readOpencodeTurnPage(db, { sessionId, limit: 10 }) + expect(page.messages.map((message) => message.info.id)).toEqual(['primary-message-1', 'primary-message-2']) + expect(page.nextCursor).toBeNull() + + const body = readOpencodeTurnBody(db, { sessionId, turnId: 'primary-message-2' }) + expect(body?.parts.map((part) => part.id)).toEqual(['primary-part-2']) + expect(readOpencodeTurnBody(db, { sessionId, turnId: 'other-message-1' })).toBeNull() + } finally { + db.close() + } + }) + + it('resolves a legacy freshopencode placeholder to one same-cwd title/time candidate', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db, { + id: 'ses_legacy_match', + directory: '/home/dan/code', + title: 'Skills from public repos', + timeCreated: baseTime + 54 * 60_000, + timeUpdated: baseTime + 2 * 60 * 60_000, + }) + insertSession(db, { + id: 'ses_same_cwd_unrelated', + directory: '/home/dan/code', + title: 'Audit build install scripts', + timeCreated: baseTime + 55 * 60_000, + timeUpdated: baseTime + 56 * 60_000, + }) + insertSession(db, { + id: 'ses_old_same_title', + directory: '/home/dan/code', + title: 'Skills from old repos', + timeCreated: baseTime - 7 * 24 * 60 * 60_000, + timeUpdated: baseTime - 7 * 24 * 60 * 60_000, + }) + insertSession(db, { + id: 'ses_other_cwd_same_title', + directory: '/home/dan/other', + title: 'Skills from public repos', + timeCreated: baseTime + 54 * 60_000, + timeUpdated: baseTime + 2 * 60 * 60_000, + }) + + const resolved = resolveOpencodeLegacySession(db, { + cwd: '/home/dan/code', + title: 'Identifying skills from GitHub repos', + createdAt: baseTime, + updatedAt: baseTime + 30_000, + }) + + expect(resolved?.id).toBe('ses_legacy_match') + expect(resolved?.directory).toBe('/home/dan/code') + } finally { + db.close() + } + }) + + it('does not resolve an ambiguous legacy freshopencode placeholder', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db, { + id: 'ses_legacy_match_a', + directory: '/home/dan/code', + title: 'Skills from public repos', + timeCreated: baseTime + 54 * 60_000, + }) + insertSession(db, { + id: 'ses_legacy_match_b', + directory: '/home/dan/code', + title: 'Public repo skills inventory', + timeCreated: baseTime + 55 * 60_000, + }) + + expect(resolveOpencodeLegacySession(db, { + cwd: '/home/dan/code', + title: 'Identifying skills from GitHub repos', + createdAt: baseTime, + })).toBeUndefined() + } finally { + db.close() + } + }) + + it('reads a turn body with JSON-parsed parts ordered by part id', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db) + insertMessage(db, 'message-body', 1_000, { role: 'assistant', providerID: 'opencode-go', modelID: 'deepseek-v4-flash' }) + insertPart(db, 'part-c-text', 'message-body', { type: 'text', text: 'Finished.' }) + insertPart(db, 'part-a-file', 'message-body', { + type: 'file', + path: 'src/App.tsx', + content: 'export function App() {}', + }) + insertPart(db, 'part-b-patch', 'message-body', { + type: 'patch', + files: [{ path: 'src/App.tsx', additions: 1, deletions: 0 }], + diff: '@@ -0,0 +1 @@', + }) + + const body = readOpencodeTurnBody(db, { sessionId, turnId: 'message-body' }) + + expect(body).toMatchObject({ + info: { + id: 'message-body', + role: 'assistant', + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + time: { + created: baseTime + 1_000, + updated: baseTime + 1_017, + }, + }, + }) + expect(body?.parts.map((part) => part.id)).toEqual(['part-a-file', 'part-b-patch', 'part-c-text']) + expect(body?.parts).toEqual([ + expect.objectContaining({ id: 'part-a-file', type: 'file', path: 'src/App.tsx' }), + expect.objectContaining({ id: 'part-b-patch', type: 'patch', files: [{ path: 'src/App.tsx', additions: 1, deletions: 0 }] }), + expect.objectContaining({ id: 'part-c-text', type: 'text', text: 'Finished.' }), + ]) + } finally { + db.close() + } + }) + + it('throws a typed schema error when required columns are missing instead of returning blank history', () => { + const db = createDatabase() + try { + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + slug text NOT NULL, + title text NOT NULL, + version text NOT NULL, + time_created integer NOT NULL + ); + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) + + let caught: unknown + try { + readOpencodeSnapshotPage(db, { sessionId, limit: 3 }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).name).toBe('OpencodeHistorySchemaError') + expect((caught as { code?: unknown }).code).toBe('OPENCODE_HISTORY_SCHEMA_ERROR') + expect((caught as { table?: unknown }).table).toBe('session') + expect((caught as { missingColumns?: unknown }).missingColumns).toEqual( + expect.arrayContaining(['directory', 'time_updated']), + ) + } finally { + db.close() + } + }) + + it('throws a typed schema error when a message required column is missing', () => { + const db = createDatabase() + try { + createOpenCodeSchema(db) + db.exec('ALTER TABLE message DROP COLUMN data') + insertSession(db) + + let caught: unknown + try { + readOpencodeTurnPage(db, { sessionId, limit: 2 }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).name).toBe('OpencodeHistorySchemaError') + expect((caught as { code?: unknown }).code).toBe('OPENCODE_HISTORY_SCHEMA_ERROR') + expect((caught as { table?: unknown }).table).toBe('message') + expect((caught as { missingColumns?: unknown }).missingColumns).toEqual(expect.arrayContaining(['data'])) + } finally { + db.close() + } + }) + + it('parses message and part JSON strings and preserves file, patch, and compaction fixtures for normalization', () => { + const db = createDatabase() + try { + seedConversation(db) + + const page = readOpencodeSnapshotPage(db, { sessionId, limit: 5 }) + + expect(page.messages[1].info).toMatchObject({ + id: 'message-2', + role: 'assistant', + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + }) + expect(typeof page.messages[1].info).toBe('object') + expect(page.messages.flatMap((message) => message.parts).filter((part) => part.type === 'file')).toEqual([ + expect.objectContaining({ + id: 'part-3', + type: 'file', + path: 'server/fresh-agent/adapters/opencode/history-query.ts', + content: 'export {}', + }), + ]) + expect(page.messages.flatMap((message) => message.parts).filter((part) => part.type === 'patch')).toEqual([ + expect.objectContaining({ + id: 'part-4', + type: 'patch', + files: [{ path: 'server/fresh-agent/adapters/opencode/history-query.ts', additions: 12, deletions: 0 }], + }), + ]) + expect(page.messages.flatMap((message) => message.parts).filter((part) => part.type === 'compaction')).toEqual([ + expect.objectContaining({ + id: 'part-5', + type: 'compaction', + summary: 'Compacted earlier DB history context.', + beforeTokens: 10_000, + afterTokens: 3_500, + }), + ]) + } finally { + db.close() + } + }) +}) diff --git a/test/unit/server/fresh-agent/opencode-history-runner.test.ts b/test/unit/server/fresh-agent/opencode-history-runner.test.ts new file mode 100644 index 000000000..a7df09c14 --- /dev/null +++ b/test/unit/server/fresh-agent/opencode-history-runner.test.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkerHistoryReader, + OpencodeHistoryReaderError, +} from '../../../../server/fresh-agent/adapters/opencode/history-runner.js' + +class FakeWorker extends EventEmitter { + terminated = 0 + postedData: unknown + execArgv: string[] + + constructor(public url: URL, public options: { workerData: unknown; execArgv: string[] }) { + super() + this.postedData = options.workerData + this.execArgv = options.execArgv + } + + terminate() { + this.terminated += 1 + return Promise.resolve(0) + } + + emitMessage(message: unknown) { this.emit('message', message) } + emitError(error: Error) { this.emit('error', error) } + emitExit(code: number) { this.emit('exit', code) } +} + +function makeReader(overrides: Partial[0]> = {}) { + const workers: FakeWorker[] = [] + const spawn = vi.fn((url: URL, options: { workerData: unknown; execArgv: string[] }) => { + const worker = new FakeWorker(url, options) + workers.push(worker) + return worker + }) + const reader = createWorkerHistoryReader({ + dbPath: '/tmp/opencode.db', + spawn: spawn as any, + timeoutMs: 50, + ...overrides, + }) + return { reader, workers, spawn } +} + +describe('createWorkerHistoryReader', () => { + it('resolves session info from an ok message and terminates the worker', async () => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitMessage({ + ok: true, + result: { + type: 'session_info', + sessionInfo: { id: 'ses-1', title: 'History' }, + }, + }) + + await expect(promise).resolves.toMatchObject({ id: 'ses-1', title: 'History' }) + expect(workers[0].terminated).toBe(1) + }) + + it('passes dbPath, request, queryModuleUrl, sentinel kind, and warning-suppression execArgv', async () => { + const { reader, workers } = makeReader() + const promise = reader.readTurnPage('ses-1', { cursor: 'opaque-cursor', limit: 3 }) + await Promise.resolve() + + const data = workers[0].postedData as any + expect(data.kind).toBe('opencode-history-worker') + expect(data.dbPath).toBe('/tmp/opencode.db') + expect(String(data.queryModuleUrl)).toContain('history-query') + expect(data.request).toEqual({ + type: 'turn_page', + sessionId: 'ses-1', + query: { cursor: 'opaque-cursor', limit: 3 }, + }) + expect(workers[0].execArgv).toEqual([...process.execArgv, '--disable-warning=ExperimentalWarning']) + + workers[0].emitMessage({ + ok: true, + result: { + type: 'turn_page', + page: { + exported: { messages: [] }, + revision: 1, + nextCursor: null, + hasMoreBefore: false, + }, + }, + }) + await promise + }) + + it('resolves not_found responses as undefined', async () => { + const { reader, workers } = makeReader() + const promise = reader.readTurnBody('ses-1', 'missing-turn') + await Promise.resolve() + + workers[0].emitMessage({ ok: false, reason: 'not_found' }) + + await expect(promise).resolves.toBeUndefined() + expect(workers[0].terminated).toBe(1) + }) + + it.each([ + ['missing_db', { ok: false, reason: 'missing_db' }], + ['schema_mismatch', { + ok: false, + reason: 'schema_mismatch', + error: { + name: 'OpencodeHistorySchemaError', + message: 'missing columns', + code: 'OPENCODE_HISTORY_SCHEMA_ERROR', + table: 'session', + missingColumns: ['directory'], + }, + }], + ['read_error', { ok: false, reason: 'read_error', error: { name: 'Error', message: 'boom' } }], + ] as const)('rejects %s responses with the typed failure reason', async (reason, message) => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitMessage(message) + + let caught: unknown + try { + await promise + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(OpencodeHistoryReaderError) + expect(caught).toMatchObject({ reason }) + }) + + it.each([ + ['ok:true without result', { ok: true }], + ['ok:true with malformed result', { ok: true, result: { type: 'session_info' } }], + ['ok:false without reason', { ok: false }], + ['ok:false with unknown reason', { ok: false, reason: 'other_error' }], + ['no ok key', { result: {} }], + ])('rejects malformed worker messages (%s)', async (_label, message) => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitMessage(message) + + await expect(promise).rejects.toThrow(/malformed/i) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on a worker error event and terminates', async () => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitError(new Error('worker crashed')) + + await expect(promise).rejects.toThrow(/worker crashed/) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects when the worker exits before sending a message', async () => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitExit(1) + + await expect(promise).rejects.toThrow(/exit/i) + }) + + it('rejects and terminates on timeout', async () => { + vi.useFakeTimers() + try { + const { reader, workers } = makeReader({ timeoutMs: 25 }) + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + const caughtPromise = promise.then( + () => undefined, + (error: unknown) => error, + ) + + await vi.advanceTimersByTimeAsync(30) + + const caught = await caughtPromise + expect(caught).toBeInstanceOf(OpencodeHistoryReaderError) + expect(caught).toMatchObject({ reason: 'read_error' }) + expect(workers[0].terminated).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('rejects if a worker returns a different result type than requested', async () => { + const { reader, workers } = makeReader() + const promise = reader.readSessionInfo('ses-1') + await Promise.resolve() + + workers[0].emitMessage({ + ok: true, + result: { + type: 'turn_page', + page: { + exported: { messages: [] }, + revision: 1, + nextCursor: null, + hasMoreBefore: false, + }, + }, + }) + + await expect(promise).rejects.toThrow(/returned turn_page/) + }) +}) diff --git a/test/unit/server/fresh-agent/opencode-history-worker.test.ts b/test/unit/server/fresh-agent/opencode-history-worker.test.ts new file mode 100644 index 000000000..3388382bc --- /dev/null +++ b/test/unit/server/fresh-agent/opencode-history-worker.test.ts @@ -0,0 +1,225 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + executeHistory, + OPENCODE_HISTORY_WORKER_KIND, +} from '../../../../server/fresh-agent/adapters/opencode/history-worker.js' + +vi.unmock('node:sqlite') + +type SqliteModule = typeof import('node:sqlite') +type DatabaseSyncConstructor = SqliteModule['DatabaseSync'] +type DatabaseSyncInstance = InstanceType + +const queryModuleUrl = new URL('../../../../server/fresh-agent/adapters/opencode/history-query.ts', import.meta.url).href + +describe('opencode history worker executeHistory', () => { + let tempDir: string + let DatabaseSync: DatabaseSyncConstructor + + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-history-worker-')) + DatabaseSync = (await import('node:sqlite')).DatabaseSync + }) + + afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) + }) + + function createDatabase(name = 'opencode.db'): DatabaseSyncInstance { + return new DatabaseSync(path.join(tempDir, name)) + } + + function createOpenCodeSchema(db: DatabaseSyncInstance): void { + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, + directory text NOT NULL, + title text NOT NULL, + model text, + cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) + } + + function insertSession(db: DatabaseSyncInstance, overrides: { id?: string; model?: string } = {}): void { + db.prepare(` + INSERT INTO session ( + id, + directory, + title, + model, + cost, + tokens_input, + tokens_output, + tokens_reasoning, + tokens_cache_read, + tokens_cache_write, + time_created, + time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + overrides.id ?? 'ses-history-worker', + '/repo', + 'Worker fixture', + overrides.model ?? JSON.stringify({ providerID: 'opencode-go', id: 'deepseek-v4-flash' }), + 0, + 1, + 2, + 3, + 4, + 5, + 1000, + 2000, + ) + } + + it('dynamically imports the query module and returns a structured result', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db) + } finally { + db.close() + } + + const response = await executeHistory({ + queryModuleUrl, + dbPath, + request: { type: 'session_info', sessionId: 'ses-history-worker' }, + }) + + expect(response).toMatchObject({ + ok: true, + result: { + type: 'session_info', + sessionInfo: { id: 'ses-history-worker', title: 'Worker fixture' }, + }, + }) + }) + + it('returns missing_db before importing the query module', async () => { + const response = await executeHistory({ + queryModuleUrl, + dbPath: path.join(tempDir, 'missing-opencode.db'), + request: { type: 'session_info', sessionId: 'missing' }, + }) + + expect(response).toEqual({ ok: false, reason: 'missing_db' }) + }) + + it('returns not_found when the query has no matching session', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = createDatabase() + try { + createOpenCodeSchema(db) + } finally { + db.close() + } + + const response = await executeHistory({ + queryModuleUrl, + dbPath, + request: { type: 'session_info', sessionId: 'missing-session' }, + }) + + expect(response).toEqual({ ok: false, reason: 'not_found' }) + }) + + it('returns schema_mismatch with typed schema details', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = createDatabase() + try { + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, + title text NOT NULL, + model text, + cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + `) + } finally { + db.close() + } + + const response = await executeHistory({ + queryModuleUrl, + dbPath, + request: { type: 'session_info', sessionId: 'ses-history-worker' }, + }) + + expect(response).toMatchObject({ + ok: false, + reason: 'schema_mismatch', + error: { + name: 'OpencodeHistorySchemaError', + code: 'OPENCODE_HISTORY_SCHEMA_ERROR', + table: 'session', + missingColumns: expect.arrayContaining(['directory']), + }, + }) + }) + + it('returns read_error with serialized error details for non-schema read failures', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = createDatabase() + try { + createOpenCodeSchema(db) + insertSession(db, { model: '{not-json' }) + } finally { + db.close() + } + + const response = await executeHistory({ + queryModuleUrl, + dbPath, + request: { type: 'session_info', sessionId: 'ses-history-worker' }, + }) + + expect(response).toMatchObject({ + ok: false, + reason: 'read_error', + error: { name: 'Error' }, + }) + expect(response.ok === false ? response.error?.message : '').toContain('Failed to parse OpenCode session.model JSON') + }) + + it('does not auto-run on import under the threaded test runtime because of the sentinel guard', () => { + expect(typeof executeHistory).toBe('function') + expect(OPENCODE_HISTORY_WORKER_KIND).toBe('opencode-history-worker') + expect(queryModuleUrl).toContain('history-query') + }) +}) 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 91f99bd32..f5eff62d5 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -65,19 +65,6 @@ function makeAdapter(manager: FakeManager, overrides: Partial { - it('rejects temporary Freshopencode ids on resume instead of treating them as durable sessions', async () => { - const manager = makeFakeManager() - const adapter = makeAdapter(manager) - - await expect(adapter.resume?.({ - requestId: 'resume-placeholder', - sessionType: 'freshopencode', - provider: 'opencode', - resumeSessionId: 'freshopencode-resume-placeholder', - })).rejects.toThrow('temporary Freshopencode id') - expect(manager.getSession).not.toHaveBeenCalled() - }) - it('creates a placeholder, materializes on first send via POST /session, and awaits idle', async () => { const manager = makeFakeManager() const adapter = makeAdapter(manager) @@ -396,7 +383,7 @@ describe('OpenCode serve adapter: history reads', () => { await adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', sessionId: 'ses_real_1' }) await adapter.getSnapshot?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }) - await adapter.getTurnPage?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, { limit: 1, revision: 12 }) + await adapter.getTurnPage?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, { limit: 1, revision: 0 }) await adapter.getTurnBody?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1', turnId: 'msg_assistant_1' }, 12) expect(manager.getSession).toHaveBeenNthCalledWith(1, 'ses_real_1') @@ -411,45 +398,12 @@ describe('OpenCode serve adapter: history reads', () => { manager.listMessages = vi.fn(async () => ({ messages: messages.slice(0, 1), nextCursor: 'NEXT' })) const adapter = makeAdapter(manager) await adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', sessionId: 'ses_real_1', cwd: '/repo/history' }) - const page = await adapter.getTurnPage?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, { cursor: 'CUR', limit: 1, revision: 5 }) + const page = await adapter.getTurnPage?.({ sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, { cursor: 'CUR', limit: 1, revision: 0 }) expect(page).toMatchObject({ nextCursor: 'NEXT', turns: [{ turnId: 'msg_user_1' }] }) expect(manager.getSession).toHaveBeenCalledWith('ses_real_1', { cwd: '/repo/history' }) expect(manager.listMessages).toHaveBeenCalledWith('ses_real_1', { limit: 1, before: 'CUR' }, { cwd: '/repo/history' }) }) - it('getTurnPage rejects stale revisions clearly', async () => { - const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', title: 'Kimi chat', time: { updated: 12 } })) - manager.listMessages = vi.fn(async () => ({ messages: messages.slice(0, 1), nextCursor: null })) - const adapter = makeAdapter(manager) - await adapter.attach?.({ sessionType: 'freshopencode', provider: 'opencode', sessionId: 'ses_real_1', cwd: '/repo/history' }) - - await expect(adapter.getTurnPage?.( - { sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, - { limit: 1, revision: 11 }, - )).rejects.toMatchObject({ - code: 'STALE_THREAD_REVISION', - currentRevision: 12, - }) - }) - - it('getTurnPage returns full bodies keyed by turn id when requested', async () => { - const manager = makeFakeManager() - manager.getSession = vi.fn(async () => ({ id: 'ses_real_1', 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' }) - - const page: any = await adapter.getTurnPage?.( - { sessionType: 'freshopencode', provider: 'opencode', threadId: 'ses_real_1' }, - { includeBodies: true, revision: 12 }, - ) - - expect(Object.keys(page.bodies)).toEqual(['msg_user_1', 'msg_assistant_1']) - expect(page.bodies.msg_user_1).toMatchObject({ turnId: 'msg_user_1', role: 'user' }) - expect(page.bodies.msg_assistant_1).toMatchObject({ turnId: 'msg_assistant_1', role: 'assistant' }) - }) - it('getTurnBody fetches a single message and normalizes it', async () => { const manager = makeFakeManager() manager.getMessage = vi.fn(async () => messages[1]) diff --git a/test/unit/server/fresh-agent/router.test.ts b/test/unit/server/fresh-agent/router.test.ts index fba190d41..cab2e2883 100644 --- a/test/unit/server/fresh-agent/router.test.ts +++ b/test/unit/server/fresh-agent/router.test.ts @@ -120,14 +120,15 @@ describe('fresh-agent router', () => { const snapshot = await request(app) .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1') .expect(200) - expect(snapshot.body.turns).toHaveLength(0) + expect(snapshot.body.turns).toHaveLength(2) + expect(snapshot.body.turns.map((turn: any) => turn.role)).toEqual(['user', 'assistant']) expect(JSON.stringify(snapshot.body)).not.toContain('providerTurnId') const firstPage = await request(app) - .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns?limit=1') + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns?revision=7&limit=1') .expect(200) expect(firstPage.body.turns).toHaveLength(1) - expect(firstPage.body.turns[0]).toMatchObject({ role: 'assistant' }) + expect(firstPage.body.turns[0]).toMatchObject({ role: 'user' }) expect(firstPage.body.nextCursor).toMatch(/^codex-cursor:v1:/) expect(JSON.stringify(firstPage.body)).not.toContain('providerTurnId') @@ -136,7 +137,7 @@ describe('fresh-agent router', () => { .query({ revision: '7', limit: '1', cursor: firstPage.body.nextCursor }) .expect(200) expect(secondPage.body.turns).toHaveLength(1) - expect(secondPage.body.turns[0]).toMatchObject({ role: 'user' }) + expect(secondPage.body.turns[0]).toMatchObject({ role: 'assistant' }) expect(JSON.stringify(secondPage.body)).not.toContain('providerTurnId') const body = await request(app) @@ -144,7 +145,7 @@ describe('fresh-agent router', () => { .expect(200) expect(body.body).toMatchObject({ turnId: secondPage.body.turns[0].turnId, - role: 'user', + role: 'assistant', threadId: 'thread-new-1', revision: 7, }) @@ -245,7 +246,7 @@ describe('fresh-agent router: snapshot served observability', () => { expect(payload.provider).toBe('codex') expect(payload.sessionType).toBe('freshcodex') expect(payload.httpStatus).toBe(200) - expect(payload.turnCount).toBe(0) + expect(payload.turnCount).toBeGreaterThan(0) expect(payload.durationMs).toBeGreaterThanOrEqual(0) expect(payload.payloadBytes).toBeGreaterThan(0) expect(payload.threadIdHash).toBeDefined() diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 34025665f..8a58efdf2 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -96,6 +96,11 @@ describe('WsHandler fresh-agent routing', () => { sessionType: 'freshcodex', provider: 'codex', cwd: '/workspace', + legacyRestoreContext: { + title: 'Legacy title', + createdAt: 1_781_291_230_743, + updatedAt: 1_781_291_259_546, + }, })) ws.send(JSON.stringify({ type: 'terminal.create', @@ -107,7 +112,11 @@ describe('WsHandler fresh-agent routing', () => { expect(runtimeManager.create).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex', provider: 'codex', - cwd: '/workspace', + legacyRestoreContext: { + title: 'Legacy title', + createdAt: 1_781_291_230_743, + updatedAt: 1_781_291_259_546, + }, })) expect(seenMessages.some((message) => message.type === 'freshAgent.created')).toBe(true) }) diff --git a/test/unit/shared/fresh-agent-turns.test.ts b/test/unit/shared/fresh-agent-turns.test.ts index 220214f66..94bdf0f62 100644 --- a/test/unit/shared/fresh-agent-turns.test.ts +++ b/test/unit/shared/fresh-agent-turns.test.ts @@ -5,8 +5,6 @@ import { freshAgentSnapshotHasUserTurn, freshAgentTurnText, getFreshAgentDisplayTurnKey, - getFreshAgentTurnIdentityKeys, - isTemporaryFreshAgentTurnId, } from '../../../shared/fresh-agent-turns.js' describe('fresh-agent display turn helpers', () => { @@ -88,26 +86,4 @@ describe('fresh-agent display turn helpers', () => { providerTurnId: 'legacy-id', })).toThrow() }) - - it('ignores temporary display ids but keeps durable message identity keys', () => { - expect(isTemporaryFreshAgentTurnId('live-user-1')).toBe(true) - expect(isTemporaryFreshAgentTurnId('__local-echo:req-1')).toBe(true) - expect(isTemporaryFreshAgentTurnId('turn-durable-1')).toBe(false) - - expect(getFreshAgentTurnIdentityKeys({ - id: 'live-user-1', - turnId: '__local-echo:req-1', - messageId: 'message-1', - })).toEqual(['message:message-1']) - - expect(getFreshAgentTurnIdentityKeys({ - id: 'turn-durable-1', - turnId: 'display-durable-1', - messageId: 'message-1', - })).toEqual([ - 'turn:display-durable-1', - 'turn:turn-durable-1', - 'message:message-1', - ]) - }) }) diff --git a/test/unit/shared/fresh-agent.test.ts b/test/unit/shared/fresh-agent.test.ts deleted file mode 100644 index 292ca5f7d..000000000 --- a/test/unit/shared/fresh-agent.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { migrateLegacyFreshAgentDurableState } from '../../../shared/fresh-agent.js' - -describe('fresh-agent shared migration', () => { - it('keeps durable Freshopencode session refs', () => { - expect(migrateLegacyFreshAgentDurableState({ - provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, - })).toEqual({ - sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, - }) - }) - - it('promotes a durable Freshopencode resume id when a legacy placeholder ref is present', () => { - expect(migrateLegacyFreshAgentDurableState({ - provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-1' }, - resumeSessionId: 'ses_real_1', - })).toEqual({ - sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, - }) - }) - - it('marks Freshopencode placeholders as non-restorable when no durable id exists', () => { - expect(migrateLegacyFreshAgentDurableState({ - provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-1' }, - })).toEqual({ - restoreError: { - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', - }, - }) - - expect(migrateLegacyFreshAgentDurableState({ - provider: 'opencode', - resumeSessionId: 'freshopencode-req-1', - })).toEqual({ - restoreError: { - code: 'RESTORE_UNAVAILABLE', - reason: 'invalid_legacy_restore_target', - }, - }) - }) -})